Skip to content

Commit 679bc1c

Browse files
authored
Add BlueskyComments component for displaying Bluesky replies (#215)
* Add BlueskyComments component for displaying threaded Bluesky replies Implements a self-contained Astro component that fetches and renders threaded replies to a pinned Bluesky post. Component handles loading, error, and empty states; displays post stats, relative timestamps, engagement metrics, and recursive nested replies sorted by engagement. * Simplify BlueskyComments rendering and wire up to blog posts Replace imperative DOM building with HTML template strings, consolidate duplicated stat formatting and CTA logic, and add comment_id frontmatter field to blog schema for conditional rendering on blog post pages. * Fix avatar sizing and render links in comment text Use global styles for dynamically inserted elements, size avatars to 1lh, and parse Bluesky rich text facets to render inline links as hyperlinks.
1 parent 55204b0 commit 679bc1c

3 files changed

Lines changed: 212 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
---
2+
interface Props {
3+
post: string;
4+
}
5+
6+
const { post } = Astro.props;
7+
const postUrl = `https://bsky.app/profile/just-be.dev/post/${post}`;
8+
const componentId = `bsky-comments-${post}`;
9+
---
10+
11+
<section
12+
id={componentId}
13+
class="bsky-comments border-2 border-fg-2 px-2 py-1 mt-2lh"
14+
aria-label="Comments"
15+
>
16+
<h2 class="font-bold mb-0">Comments</h2>
17+
<p class="bsky-loading text-fg-2">Loading comments...</p>
18+
<div class="bsky-error hidden text-fg-2">
19+
<p>Could not load comments.</p>
20+
</div>
21+
<div class="bsky-content hidden"></div>
22+
</section>
23+
24+
<script define:vars={{ post, postUrl, componentId }}>
25+
const AT_URI = `at://just-be.dev/app.bsky.feed.post/${post}`;
26+
27+
const container = document.getElementById(componentId);
28+
if (!container) throw new Error(`BlueskyComments: #${componentId} not found`);
29+
30+
const loadingEl = container.querySelector(".bsky-loading");
31+
const errorEl = container.querySelector(".bsky-error");
32+
const contentEl = container.querySelector(".bsky-content");
33+
34+
const TIME_UNITS = [
35+
[60, "s"],
36+
[60, "m"],
37+
[24, "h"],
38+
[30, "d"],
39+
[12, "mo"],
40+
[Infinity, "y"],
41+
];
42+
43+
function esc(str) {
44+
const el = document.createElement("span");
45+
el.textContent = str;
46+
return el.innerHTML;
47+
}
48+
49+
function formatRelativeTime(isoDate) {
50+
let diff = Math.floor((Date.now() - new Date(isoDate).getTime()) / 1000);
51+
for (const [threshold, suffix] of TIME_UNITS) {
52+
if (diff < threshold) return `${Math.floor(diff)}${suffix}`;
53+
diff /= threshold;
54+
}
55+
}
56+
57+
function formatStats(replyCount, repostCount, likeCount) {
58+
const parts = [];
59+
if (replyCount > 0) parts.push(`${replyCount} ${replyCount === 1 ? "reply" : "replies"}`);
60+
if (repostCount > 0) parts.push(`${repostCount} ${repostCount === 1 ? "repost" : "reposts"}`);
61+
if (likeCount > 0) parts.push(`${likeCount} ${likeCount === 1 ? "like" : "likes"}`);
62+
return parts;
63+
}
64+
65+
function renderRichText(record) {
66+
const text = record?.text || "";
67+
const facets = record?.facets;
68+
if (!facets || facets.length === 0) return esc(text);
69+
70+
const encoder = new TextEncoder();
71+
const decoder = new TextDecoder();
72+
const bytes = encoder.encode(text);
73+
const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart);
74+
75+
let html = "";
76+
let lastByte = 0;
77+
78+
for (const facet of sorted) {
79+
const { byteStart, byteEnd } = facet.index;
80+
if (byteStart > lastByte) {
81+
html += esc(decoder.decode(bytes.slice(lastByte, byteStart)));
82+
}
83+
const facetText = decoder.decode(bytes.slice(byteStart, byteEnd));
84+
const link = facet.features?.find((f) => f.$type === "app.bsky.richtext.facet#link");
85+
if (link) {
86+
html += `<a href="${esc(link.uri)}" target="_blank" rel="noopener noreferrer" class="bsky-text-link">${esc(facetText)}</a>`;
87+
} else {
88+
html += esc(facetText);
89+
}
90+
lastByte = byteEnd;
91+
}
92+
93+
if (lastByte < bytes.length) {
94+
html += esc(decoder.decode(bytes.slice(lastByte)));
95+
}
96+
return html;
97+
}
98+
99+
function validReplies(replies) {
100+
return (replies || []).filter((r) => r?.$type === "app.bsky.feed.defs#threadViewPost");
101+
}
102+
103+
function renderComment(reply, isTopLevel) {
104+
const { post } = reply;
105+
if (!post) return "";
106+
107+
const { author = {}, indexedAt = "", replyCount = 0, repostCount = 0, likeCount = 0 } = post;
108+
const richText = renderRichText(post.record);
109+
const rkey = (post.uri || "").split("/").pop();
110+
const bskyUrl = `https://bsky.app/profile/${esc(author.did)}/post/${esc(rkey)}`;
111+
const profileUrl = `https://bsky.app/profile/${esc(author.did)}`;
112+
const stats = formatStats(replyCount, repostCount, likeCount)
113+
.map((s) => `<span>${s}</span>`)
114+
.join("");
115+
const nested = validReplies(reply.replies)
116+
.map((r) => renderComment(r, false))
117+
.join("");
118+
119+
return `
120+
<div class="bsky-comment ${isTopLevel ? "mb-1" : ""}">
121+
<div class="flex items-center gap-1 text-sm">
122+
${author.avatar ? `<img src="${esc(author.avatar)}" alt="" class="bsky-avatar" loading="lazy">` : ""}
123+
<a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="font-bold text-fg-0 no-underline">${esc(author.displayName || author.handle || "Unknown")}</a>
124+
<span class="text-fg-2">@${esc(author.handle || "")}</span>
125+
<span class="text-fg-2">${formatRelativeTime(indexedAt)}</span>
126+
</div>
127+
${richText ? `<p class="my-0 whitespace-pre-wrap">${richText}</p>` : ""}
128+
<div class="flex gap-2 text-fg-2 text-sm">
129+
${stats}
130+
<a href="${bskyUrl}" target="_blank" rel="noopener noreferrer" class="bsky-reply-link text-fg-2">reply</a>
131+
</div>
132+
${nested ? `<div class="bsky-nested border-l-2 border-fg-2 pl-2 mt-0.5">${nested}</div>` : ""}
133+
</div>`;
134+
}
135+
136+
async function loadComments() {
137+
try {
138+
const res = await fetch(
139+
`https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(AT_URI)}&depth=6`
140+
);
141+
if (!res.ok) throw new Error(`API returned ${res.status}`);
142+
143+
const { thread } = await res.json();
144+
if (!thread || thread.$type !== "app.bsky.feed.defs#threadViewPost") {
145+
throw new Error("Invalid thread response");
146+
}
147+
148+
const { replyCount = 0, repostCount = 0, likeCount = 0 } = thread.post;
149+
const replies = validReplies(thread.replies);
150+
const statsHtml = formatStats(replyCount, repostCount, likeCount).join(" / ");
151+
const ctaText =
152+
replies.length === 0 ? "Be the first to reply on Bluesky" : "Reply on Bluesky";
153+
const commentsHtml = replies
154+
.sort((a, b) => (b?.post?.likeCount ?? 0) - (a?.post?.likeCount ?? 0))
155+
.map((r) => renderComment(r, true))
156+
.join("");
157+
158+
loadingEl.classList.add("hidden");
159+
contentEl.classList.remove("hidden");
160+
contentEl.innerHTML = `
161+
${statsHtml ? `<p class="text-fg-2 my-0">${statsHtml}</p>` : ""}
162+
<p><a href="${postUrl}" target="_blank" rel="noopener noreferrer" class="bsky-reply-link text-fg-0 font-bold underline">${ctaText}</a></p>
163+
${commentsHtml ? `<div class="bsky-thread">${commentsHtml}</div>` : ""}`;
164+
} catch (err) {
165+
console.error("BlueskyComments:", err);
166+
loadingEl.classList.add("hidden");
167+
errorEl.classList.remove("hidden");
168+
}
169+
}
170+
171+
loadComments();
172+
</script>
173+
174+
<style>
175+
.bsky-comments h2 {
176+
margin-top: 0;
177+
}
178+
</style>
179+
180+
<style is:global>
181+
.bsky-avatar {
182+
display: inline-block;
183+
width: 1lh;
184+
height: 1lh;
185+
border-radius: 50%;
186+
vertical-align: middle;
187+
flex-shrink: 0;
188+
object-fit: cover;
189+
}
190+
191+
.bsky-reply-link:hover {
192+
filter: invert(1);
193+
}
194+
195+
.bsky-text-link {
196+
text-decoration: underline;
197+
color: var(--foreground0);
198+
}
199+
200+
.bsky-text-link:hover {
201+
filter: invert(1);
202+
}
203+
204+
.bsky-comment p {
205+
margin-top: 0;
206+
margin-bottom: 0;
207+
}
208+
</style>

src/content/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const blogSchema = z.object({
66
date: z.coerce.date(),
77
tags: z.array(z.string()).default([]),
88
draft: z.boolean().default(false),
9+
comment_id: z.string().optional(),
910
code: z.string().optional(),
1011
slugs: z.array(z.string()).optional(),
1112
});

src/pages/blog/[slug].astro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import FormattedDate from "@/components/FormattedDate.astro";
44
import Tag from "@/components/Tag.astro";
55
import Link from "@/components/Link.astro";
66
import BackLink from "@/components/BackLink.astro";
7+
import BlueskyComments from "@/components/BlueskyComments.astro";
78
import MDContent from "@/components/MDContent.astro";
89
import Layout from "@/layouts/Layout.astro";
910
import { Code } from "@/utils/code";
@@ -65,6 +66,8 @@ const code = post.data.code ? post.data.code.toUpperCase() : Code.fromId(post.id
6566
<MDContent Content={Content} components={{ a: Link }} />
6667
</div>
6768

69+
{post.data.comment_id && <BlueskyComments post={post.data.comment_id} />}
70+
6871
<BackLink href="/blog" label="Back to blog" />
6972
</article>
7073
</Layout>

0 commit comments

Comments
 (0)