diff --git a/src/lib/pds.ts b/src/lib/pds.ts index a6e4f15..bc613c3 100644 --- a/src/lib/pds.ts +++ b/src/lib/pds.ts @@ -342,5 +342,96 @@ export function invalidateAbout(): void { aboutCache = null; } +// --- Bluesky feed --- + +const BSKY_PUBLIC_API = "https://public.api.bsky.app"; +const FEED_TTL = 300_000; // 5 minutes + +export interface FeedPost { + uri: string; + cid: string; + text: string; + createdAt: string; + likeCount: number; + repostCount: number; + replyCount: number; + images: Array<{ thumb: string; alt: string }>; +} + +let feedCache: CacheEntry | null = null; + +export async function getBlueskyFeed(limit = 3): Promise { + const stale = feedCache; + if (isFresh(feedCache)) return feedCache.data.slice(0, limit); + + try { + const params = new URLSearchParams({ + actor: DID, + limit: String(Math.min(limit * 2, 30)), // fetch extra to filter replies/reposts + filter: "posts_no_replies", + }); + + const res = await fetch(`${BSKY_PUBLIC_API}/xrpc/app.bsky.feed.getAuthorFeed?${params}`); + if (!res.ok) { + console.error("Bluesky feed fetch failed:", res.status); + return stale?.data?.slice(0, limit) ?? []; + } + + const data = (await res.json()) as { + feed: Array<{ + post: { + uri: string; + cid: string; + author: { did: string }; + record: { text?: string; createdAt?: string }; + likeCount?: number; + repostCount?: number; + replyCount?: number; + embed?: { + $type?: string; + images?: Array<{ thumb?: string; alt?: string }>; + }; + }; + reason?: unknown; + }>; + }; + + const posts: FeedPost[] = []; + + for (const item of data.feed) { + // Skip reposts + if (item.reason) continue; + // Only own posts + if (item.post.author.did !== DID) continue; + + const images: FeedPost["images"] = []; + if (item.post.embed?.$type === "app.bsky.embed.images#view" && item.post.embed.images) { + for (const img of item.post.embed.images) { + if (img.thumb) images.push({ thumb: img.thumb, alt: img.alt || "" }); + } + } + + posts.push({ + uri: item.post.uri, + cid: item.post.cid, + text: item.post.record.text || "", + createdAt: item.post.record.createdAt || "", + likeCount: item.post.likeCount || 0, + repostCount: item.post.repostCount || 0, + replyCount: item.post.replyCount || 0, + images, + }); + + if (posts.length >= limit) break; + } + + feedCache = { data: posts, expiresAt: Date.now() + FEED_TTL }; + return posts; + } catch (err) { + console.error("Failed to fetch Bluesky feed:", err); + return stale?.data?.slice(0, limit) ?? []; + } +} + // Re-export constants for backward compatibility export { HANDLE, DID, PDS_URL } from "./constants"; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 57229df..45124b4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -22,6 +22,24 @@ export function excerpt(content: string, maxLen = DEFAULT_EXCERPT_LENGTH): strin return plain.slice(0, maxLen).replace(/\s+\S*$/, "") + "\u2026"; } +export function relativeTime(iso: string): string { + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + return formatDate(iso); +} + +export function bskyPostUrl(uri: string, handle: string): string { + // at://did:plc:.../app.bsky.feed.post/rkey -> https://bsky.app/profile/handle/post/rkey + const rkey = uri.split("/").pop() || ""; + return `https://bsky.app/profile/${handle}/post/${rkey}`; +} + export function escapeXml(s: string): string { return s .replace(/&/g, "&") diff --git a/src/pages/index.astro b/src/pages/index.astro index 68b411e..af938e8 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,15 +1,16 @@ --- import Base from "../layouts/Base.astro"; -import { getBlogEntries, getProfile, getAbout } from "../lib/pds"; +import { getBlogEntries, getProfile, getAbout, getBlueskyFeed } from "../lib/pds"; import { HANDLE, SESSION_DID_COOKIE } from "../lib/constants"; import { isOwner } from "../lib/auth"; -import { formatDate, excerpt } from "../lib/utils"; +import { formatDate, excerpt, relativeTime, bskyPostUrl } from "../lib/utils"; import { marked } from "marked"; import DOMPurify from "isomorphic-dompurify"; const profile = await getProfile(); const entries = await getBlogEntries(); const aboutRaw = await getAbout(); +const feedPosts = await getBlueskyFeed(3); const aboutHtml = DOMPurify.sanitize(await marked.parse(aboutRaw), { ADD_ATTR: ["target"], @@ -95,6 +96,43 @@ const isAuthor = sessionDid ? isOwner(sessionDid) : false; ))} )} + + {/* Bluesky feed */} + {feedPosts.length > 0 && ( +
+

recent on bluesky

+ +
+ )}