an atproto based link aggregator
5
fork

Configure Feed

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

Refactor: Extract shared code into reusable modules

- Create $lib/types.ts with AuthorProfile type (uses Pick from lexicon types)
- Create $lib/server/profiles.ts with fetchProfile, fetchProfiles, getProfileOrFallback
- Create $lib/utils/formatting.ts with formatTimeAgo (compact option), getDomain
- Update all route files to use shared profile helpers
- Update Svelte components to use shared formatting utilities

This eliminates duplicate code across:
- 4 files with profile fetching logic
- 4 files with AuthorProfile/UserProfile interfaces
- 2 files with formatTimeAgo implementations
- 2 files with getDomain implementations

馃 Generated with [Claude Code](https://claude.com/claude-code)

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

+133 -224
+3 -31
src/lib/components/PostList.svelte
··· 1 1 <script lang="ts"> 2 2 import Avatar from './Avatar.svelte'; 3 3 import VoteButton from './VoteButton.svelte'; 4 - 5 - interface Author { 6 - did: string; 7 - handle: string; 8 - avatar?: string; 9 - } 4 + import { formatTimeAgo, getDomain } from '$lib/utils/formatting'; 5 + import type { AuthorProfile } from '$lib/types'; 10 6 11 7 interface Post { 12 8 uri: string; ··· 14 10 url: string; 15 11 title: string; 16 12 createdAt: string; 17 - author: Author; 13 + author: AuthorProfile; 18 14 commentCount?: number; 19 15 voteCount?: number; 20 16 userVote?: number; ··· 27 23 } 28 24 29 25 let { posts, emptyMessage = 'No posts yet.', canVote = false }: Props = $props(); 30 - 31 - function formatTimeAgo(dateString: string): string { 32 - const date = new Date(dateString); 33 - const now = new Date(); 34 - const diffMs = now.getTime() - date.getTime(); 35 - const diffMins = Math.floor(diffMs / 60000); 36 - const diffHours = Math.floor(diffMs / 3600000); 37 - const diffDays = Math.floor(diffMs / 86400000); 38 - 39 - if (diffMins < 1) return 'just now'; 40 - if (diffMins < 60) return `${diffMins} minutes ago`; 41 - if (diffHours < 24) return `${diffHours} hours ago`; 42 - if (diffDays === 1) return 'yesterday'; 43 - if (diffDays < 30) return `${diffDays} days ago`; 44 - return date.toLocaleDateString(); 45 - } 46 - 47 - function getDomain(url: string): string { 48 - try { 49 - return new URL(url).hostname.replace(/^www\./, ''); 50 - } catch { 51 - return url; 52 - } 53 - } 54 26 </script> 55 27 56 28 {#if posts.length === 0}
+60
src/lib/server/profiles.ts
··· 1 + /** 2 + * Bluesky profile fetching utilities 3 + */ 4 + 5 + import type { AuthorProfile } from '$lib/types'; 6 + 7 + const BSKY_PUBLIC_API = 'https://public.api.bsky.app/xrpc'; 8 + 9 + /** 10 + * Fetch a single profile by DID 11 + */ 12 + export async function fetchProfile(did: string): Promise<AuthorProfile | null> { 13 + try { 14 + const res = await fetch( 15 + `${BSKY_PUBLIC_API}/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 16 + ); 17 + if (!res.ok) return null; 18 + const data = await res.json(); 19 + return { 20 + did: data.did, 21 + handle: data.handle, 22 + avatar: data.avatar 23 + }; 24 + } catch { 25 + return null; 26 + } 27 + } 28 + 29 + /** 30 + * Fetch multiple profiles in parallel, returning a Map keyed by DID 31 + */ 32 + export async function fetchProfiles(dids: string[]): Promise<Map<string, AuthorProfile>> { 33 + const profiles = new Map<string, AuthorProfile>(); 34 + const uniqueDids = [...new Set(dids)]; 35 + 36 + const results = await Promise.all(uniqueDids.map((did) => fetchProfile(did))); 37 + 38 + for (const profile of results) { 39 + if (profile) { 40 + profiles.set(profile.did, profile); 41 + } 42 + } 43 + 44 + return profiles; 45 + } 46 + 47 + /** 48 + * Get a profile from a map, with fallback to truncated DID if not found 49 + */ 50 + export function getProfileOrFallback( 51 + profiles: Map<string, AuthorProfile>, 52 + did: string 53 + ): AuthorProfile { 54 + return ( 55 + profiles.get(did) ?? { 56 + did, 57 + handle: did.slice(0, 20) + '...' 58 + } 59 + ); 60 + }
+11
src/lib/types.ts
··· 1 + /** 2 + * Shared type definitions for the application 3 + */ 4 + 5 + import type { ProfileViewBasic } from '$lib/lexicons/app/bsky/actor/defs.defs'; 6 + 7 + /** 8 + * Minimal profile info used for displaying authors. 9 + * Subset of the full Bluesky ProfileViewBasic. 10 + */ 11 + export type AuthorProfile = Pick<ProfileViewBasic, 'did' | 'handle' | 'avatar'>;
+45
src/lib/utils/formatting.ts
··· 1 + /** 2 + * Formatting utilities 3 + */ 4 + 5 + /** 6 + * Format a date as a relative time string. 7 + * @param dateString - ISO date string 8 + * @param compact - If true, use compact format ("2d" vs "2 days ago") 9 + */ 10 + export function formatTimeAgo(dateString: string, compact = false): string { 11 + const date = new Date(dateString); 12 + const now = new Date(); 13 + const diffMs = now.getTime() - date.getTime(); 14 + const diffMins = Math.floor(diffMs / 60000); 15 + const diffHours = Math.floor(diffMs / 3600000); 16 + const diffDays = Math.floor(diffMs / 86400000); 17 + 18 + if (compact) { 19 + if (diffMins < 1) return 'now'; 20 + if (diffMins < 60) return `${diffMins}m`; 21 + if (diffHours < 24) return `${diffHours}h`; 22 + if (diffDays < 7) return `${diffDays}d`; 23 + if (diffDays < 365) 24 + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); 25 + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); 26 + } 27 + 28 + if (diffMins < 1) return 'just now'; 29 + if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`; 30 + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; 31 + if (diffDays === 1) return 'yesterday'; 32 + if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; 33 + return date.toLocaleDateString(); 34 + } 35 + 36 + /** 37 + * Extract the domain from a URL, removing "www." prefix 38 + */ 39 + export function getDomain(url: string): string { 40 + try { 41 + return new URL(url).hostname.replace(/^www\./, ''); 42 + } catch { 43 + return url; 44 + } 45 + }
+2 -28
src/routes/+layout.server.ts
··· 1 1 import type { LayoutServerLoad } from './$types'; 2 - 3 - interface UserProfile { 4 - did: string; 5 - handle: string; 6 - avatar?: string; 7 - } 8 - 9 - async function fetchProfile(did: string): Promise<UserProfile | null> { 10 - try { 11 - const res = await fetch( 12 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 13 - ); 14 - if (!res.ok) return null; 15 - const data = await res.json(); 16 - return { 17 - did: data.did, 18 - handle: data.handle, 19 - avatar: data.avatar 20 - }; 21 - } catch { 22 - return null; 23 - } 24 - } 2 + import { fetchProfile } from '$lib/server/profiles'; 25 3 26 4 export const load: LayoutServerLoad = async ({ locals }) => { 27 - let user: UserProfile | null = null; 28 - 29 - if (locals.did) { 30 - user = await fetchProfile(locals.did); 31 - } 5 + const user = locals.did ? await fetchProfile(locals.did) : null; 32 6 33 7 return { 34 8 did: locals.did,
+2 -43
src/routes/+page.server.ts
··· 2 2 import { db } from '$lib/server/db'; 3 3 import { posts, comments, votes } from '$lib/server/db/schema'; 4 4 import { desc, eq, count, and, inArray } from 'drizzle-orm'; 5 - 6 - interface AuthorProfile { 7 - did: string; 8 - handle: string; 9 - avatar?: string; 10 - } 11 - 12 - async function fetchProfiles(dids: string[]): Promise<Map<string, AuthorProfile>> { 13 - const profiles = new Map<string, AuthorProfile>(); 14 - 15 - // Fetch profiles in parallel (batch of unique DIDs) 16 - const uniqueDids = [...new Set(dids)]; 17 - const results = await Promise.all( 18 - uniqueDids.map(async (did) => { 19 - try { 20 - const res = await fetch( 21 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 22 - ); 23 - if (!res.ok) return null; 24 - const data = await res.json(); 25 - return { 26 - did: data.did, 27 - handle: data.handle, 28 - avatar: data.avatar 29 - } as AuthorProfile; 30 - } catch { 31 - return null; 32 - } 33 - }) 34 - ); 35 - 36 - for (const profile of results) { 37 - if (profile) { 38 - profiles.set(profile.did, profile); 39 - } 40 - } 41 - 42 - return profiles; 43 - } 5 + import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 44 6 45 7 export const load: PageServerLoad = async ({ locals }) => { 46 8 // Fetch recent posts with comment counts and vote counts ··· 85 47 // Combine posts with author profiles and user votes 86 48 const postsWithData = recentPosts.map((post) => ({ 87 49 ...post, 88 - author: profiles.get(post.authorDid) ?? { 89 - did: post.authorDid, 90 - handle: post.authorDid.slice(0, 20) + '...' 91 - }, 50 + author: getProfileOrFallback(profiles, post.authorDid), 92 51 userVote: userVotes.get(post.uri) ?? 0 93 52 })); 94 53
+2 -42
src/routes/new/+page.server.ts
··· 2 2 import { db } from '$lib/server/db'; 3 3 import { posts, comments, votes } from '$lib/server/db/schema'; 4 4 import { desc, eq, count, and, inArray } from 'drizzle-orm'; 5 - 6 - interface AuthorProfile { 7 - did: string; 8 - handle: string; 9 - avatar?: string; 10 - } 11 - 12 - async function fetchProfiles(dids: string[]): Promise<Map<string, AuthorProfile>> { 13 - const profiles = new Map<string, AuthorProfile>(); 14 - const uniqueDids = [...new Set(dids)]; 15 - 16 - const results = await Promise.all( 17 - uniqueDids.map(async (did) => { 18 - try { 19 - const res = await fetch( 20 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 21 - ); 22 - if (!res.ok) return null; 23 - const data = await res.json(); 24 - return { 25 - did: data.did, 26 - handle: data.handle, 27 - avatar: data.avatar 28 - } as AuthorProfile; 29 - } catch { 30 - return null; 31 - } 32 - }) 33 - ); 34 - 35 - for (const profile of results) { 36 - if (profile) { 37 - profiles.set(profile.did, profile); 38 - } 39 - } 40 - 41 - return profiles; 42 - } 5 + import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 43 6 44 7 export const load: PageServerLoad = async ({ locals }) => { 45 8 // Fetch posts ordered by creation time (newest first) with comment counts and vote counts ··· 82 45 83 46 const postsWithData = recentPosts.map((post) => ({ 84 47 ...post, 85 - author: profiles.get(post.authorDid) ?? { 86 - did: post.authorDid, 87 - handle: post.authorDid.slice(0, 20) + '...' 88 - }, 48 + author: getProfileOrFallback(profiles, post.authorDid), 89 49 userVote: userVotes.get(post.uri) ?? 0 90 50 })); 91 51
+3 -49
src/routes/post/[rkey]/+page.server.ts
··· 8 8 import { cidForLex } from '@atproto/lex-cbor'; 9 9 import * as comment from '$lib/lexicons/one/papili/comment.defs'; 10 10 import type { l } from '@atproto/lex'; 11 - 12 - interface AuthorProfile { 13 - did: string; 14 - handle: string; 15 - avatar?: string; 16 - } 17 - 18 - async function fetchProfiles(dids: string[]): Promise<Map<string, AuthorProfile>> { 19 - const profiles = new Map<string, AuthorProfile>(); 20 - const uniqueDids = [...new Set(dids)]; 21 - 22 - const results = await Promise.all( 23 - uniqueDids.map(async (did) => { 24 - try { 25 - const res = await fetch( 26 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 27 - ); 28 - if (!res.ok) return null; 29 - const data = await res.json(); 30 - return { 31 - did: data.did, 32 - handle: data.handle, 33 - avatar: data.avatar 34 - } as AuthorProfile; 35 - } catch { 36 - return null; 37 - } 38 - }) 39 - ); 40 - 41 - for (const profile of results) { 42 - if (profile) { 43 - profiles.set(profile.did, profile); 44 - } 45 - } 46 - 47 - return profiles; 48 - } 11 + import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 49 12 50 13 export const load: PageServerLoad = async ({ params, locals }) => { 51 14 const { rkey } = params; ··· 86 49 } 87 50 } 88 51 89 - // Get author for post 90 - const postAuthor = profiles.get(post.authorDid) ?? { 91 - did: post.authorDid, 92 - handle: post.authorDid.slice(0, 20) + '...' 93 - }; 94 - 95 52 // Build comments with authors and votes 96 53 const commentsWithAuthors = postComments.map((c) => ({ 97 54 ...c, 98 - author: profiles.get(c.authorDid) ?? { 99 - did: c.authorDid, 100 - handle: c.authorDid.slice(0, 20) + '...' 101 - }, 55 + author: getProfileOrFallback(profiles, c.authorDid), 102 56 userVote: userVotes.get(c.uri) ?? 0 103 57 })); 104 58 105 59 return { 106 60 post: { 107 61 ...post, 108 - author: postAuthor, 62 + author: getProfileOrFallback(profiles, post.authorDid), 109 63 userVote: userVotes.get(post.uri) ?? 0 110 64 }, 111 65 comments: commentsWithAuthors
+5 -31
src/routes/post/[rkey]/+page.svelte
··· 3 3 import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 4 4 import Avatar from '$lib/components/Avatar.svelte'; 5 5 import VoteButton from '$lib/components/VoteButton.svelte'; 6 + import { formatTimeAgo, getDomain } from '$lib/utils/formatting'; 7 + import type { AuthorProfile } from '$lib/types'; 6 8 7 9 let { data, form } = $props(); 8 10 let canVote = $derived(!!data.user); ··· 21 23 parentUri: string | null; 22 24 voteCount: number; 23 25 userVote: number; 24 - author: { 25 - did: string; 26 - handle: string; 27 - avatar?: string; 28 - }; 26 + author: AuthorProfile; 29 27 } 30 28 31 29 function startReply(comment: Comment) { ··· 69 67 } 70 68 71 69 let commentTree = $derived(buildCommentTree(data.comments)); 72 - 73 - function formatTimeAgo(dateString: string): string { 74 - const date = new Date(dateString); 75 - const now = new Date(); 76 - const diffMs = now.getTime() - date.getTime(); 77 - const diffMins = Math.floor(diffMs / 60000); 78 - const diffHours = Math.floor(diffMs / 3600000); 79 - const diffDays = Math.floor(diffMs / 86400000); 80 - 81 - if (diffMins < 1) return 'now'; 82 - if (diffMins < 60) return `${diffMins}m`; 83 - if (diffHours < 24) return `${diffHours}h`; 84 - if (diffDays < 7) return `${diffDays}d`; 85 - if (diffDays < 365) return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); 86 - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); 87 - } 88 - 89 - function getDomain(url: string): string { 90 - try { 91 - return new URL(url).hostname.replace(/^www\./, ''); 92 - } catch { 93 - return url; 94 - } 95 - } 96 70 </script> 97 71 98 72 <svelte:head> ··· 136 110 showHandle 137 111 link 138 112 /> 139 - 路 {formatTimeAgo(data.post.createdAt)} 113 + 路 {formatTimeAgo(data.post.createdAt, true)} 140 114 </p> 141 115 </div> 142 116 </header> ··· 253 227 link 254 228 /> 255 229 <span class="comment-time" title={new Date(comment.createdAt).toLocaleString()}> 256 - {formatTimeAgo(comment.createdAt)} 230 + {formatTimeAgo(comment.createdAt, true)} 257 231 </span> 258 232 {#if canVote && !isCollapsed} 259 233 <div class="comment-vote">