|
| 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> |
0 commit comments