// Bluesky feed component — server-rendered, no client JS. import { raw } from "hono/html"; import { getAuthorFeed, type FeedPost } from "../data.ts"; function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function timeAgo(dateStr: string): string { const now = Date.now(); const then = new Date(dateStr).getTime(); const diff = now - then; const minutes = Math.floor(diff / 60000); if (minutes < 1) return "just now"; 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 new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", }); } function postUrl(uri: string): string { // at://did:plc:.../app.bsky.feed.post/rkey -> bsky.app link const parts = uri.split("/"); const did = parts[2]; const rkey = parts[parts.length - 1]; return `https://bsky.app/profile/${did}/post/${rkey}`; } // Linkify URLs, @mentions, and facets in post text function truncateUrl(url: string, max = 40): string { try { const u = new URL(url); const path = u.pathname + u.search; const display = u.hostname + (path.length > 1 ? path : ""); return display.length > max ? display.slice(0, max) + "…" : display; } catch { return url.length > max ? url.slice(0, max) + "…" : url; } } function linkifyText(text: string): string { const escaped = escapeHtml(text); return escaped // URLs — truncate display text .replace( /(https?:\/\/[^\s<]+)/g, (_match, url) => `${truncateUrl(url)}` ) // @mentions .replace( /@([\w.-]+\.[\w.-]+)/g, '@$1' ); } function renderImages(images: NonNullable): string { if (images.length === 1) { return `
${escapeHtml(images[0].alt)}
`; } const grid = images .map( (img) => `${escapeHtml(img.alt)}` ) .join(""); return `
${grid}
`; } function renderExternal(ext: NonNullable): string { return ` ${ext.thumb ? `` : ""}
${escapeHtml(ext.title)}
${escapeHtml(ext.description).slice(0, 120)}
${escapeHtml(new URL(ext.uri).hostname)}
`; } function renderQuote(qp: NonNullable): string { return `
${qp.author.avatar ? `` : ""} ${escapeHtml(qp.author.displayName ?? qp.author.handle)} @${escapeHtml(qp.author.handle)}
${linkifyText(qp.text).slice(0, 300)}
`; } function renderPost(post: FeedPost): string { const url = postUrl(post.uri); return `
${post.author.avatar ? `` : ""}
${escapeHtml(post.author.displayName ?? post.author.handle)} ${timeAgo(post.createdAt)}
${linkifyText(post.text)}
${post.images ? renderImages(post.images) : ""} ${post.external ? renderExternal(post.external) : ""} ${post.quotedPost ? renderQuote(post.quotedPost) : ""}
${post.replyCount ? `💬 ${post.replyCount}` : ""} ${post.repostCount ? `🔁 ${post.repostCount}` : ""} ${post.likeCount ? `♥ ${post.likeCount}` : ""}
`; } export async function Feed({ limit = 10 }: { limit?: number }) { const posts = await getAuthorFeed(limit); if (posts.length === 0) { return
No posts yet.
; } const html = posts.map(renderPost).join(""); return (
{raw(html)}
); }