Skip to content

Instantly share code, notes, and snippets.

@briancorbinxyz
Created November 29, 2024 23:43
Show Gist options
  • Save briancorbinxyz/9eacba0be0bc1affdc6f14a0abe52470 to your computer and use it in GitHub Desktop.
Save briancorbinxyz/9eacba0be0bc1affdc6f14a0abe52470 to your computer and use it in GitHub Desktop.
Bluesky Comments Component for Quartz
import { AppBskyFeedDefs, AppBskyFeedGetPostThread } from "@atproto/api"
const getPostThread = async (uri: string) => {
const params = new URLSearchParams({ uri })
const res = await fetch(
"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?" + params.toString(),
{
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
},
)
if (!res.ok) throw new Error("Failed to fetch post thread")
const data = (await res.json()) as AppBskyFeedGetPostThread.OutputSchema
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
throw new Error("Could not find thread")
}
return data.thread
}
const sortByLikes = (a: unknown, b: unknown) => {
if (!AppBskyFeedDefs.isThreadViewPost(a) || !AppBskyFeedDefs.isThreadViewPost(b)) {
return 0
}
return (b.post.likeCount ?? 0) - (a.post.likeCount ?? 0)
}
// Track how many comments to show
let visibleCount = 1
function renderReply(reply: AppBskyFeedDefs.ThreadViewPost, thread: AppBskyFeedDefs.ThreadViewPost): string {
const nestedAuthor = reply.post.author
const isNestedOriginalAuthor = nestedAuthor.did === thread.post.author.did
const nestedReplies = reply.replies?.map(nestedReply => renderReply(nestedReply as AppBskyFeedDefs.ThreadViewPost, thread)).join("") || ""
return `
<div class="comments-comment">
<div class="comments-comment-content">
<a class="comments-comment-header" href="https://bsky.app/profile/${nestedAuthor.did}" target="_blank" rel="noreferrer noopener">
${
nestedAuthor.avatar
? `<img src="${nestedAuthor.avatar}" alt="avatar" class="comments-comment-avatar" />`
: `<div class="comments-comment-avatar"></div>`
}
<p class="comments-comment-author">
${nestedAuthor.displayName ?? nestedAuthor.handle}
<span class="handle">@${nestedAuthor.handle}</span>
${isNestedOriginalAuthor ? '<span class="author-label">Author</span>' : ""}
</p>
</a>
<a href="https://bsky.app/profile/${nestedAuthor.did}/post/${reply.post.uri.split("/").pop()}"
target="_blank" rel="noreferrer noopener">
<p>${(reply.post.record as { text: string }).text}</p>
<div class="comments-comment-actions">
<span class="comments-comment-actions-item">
<svg xmlns="http://www.w3.org/2000/svg" fill="${reply.post.viewer?.like ? "red" : "none"}" viewBox="0 0 24 24" stroke-width="1.5" stroke="${reply.post.viewer?.like ? "red" : "currentColor"}">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
</svg>
<span>${reply.post.likeCount ?? 0}</span>
</span>
<span class="comments-comment-actions-item">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" />
</svg>
<span>${reply.post.replyCount ?? 0}</span>
</span>
<span class="comments-comment-actions-item">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
</svg>
<span>${reply.post.repostCount ?? 0}</span>
</span>
</div>
</a>
</div>
${nestedReplies ? `<div class="comments-replies">${nestedReplies}</div>` : ""}
</div>
`
}
function renderComments(container: HTMLElement, thread: AppBskyFeedDefs.ThreadViewPost) {
const listContainer = container.querySelector(".comments-list")
if (!listContainer) return
// Update stats
const statsContainer = container.querySelector(".comments-stats")
if (statsContainer) {
console.log("Viewer", thread.post.viewer)
statsContainer.innerHTML = `
<span class="comments-stats-item">
<svg xmlns="http://www.w3.org/2000/svg" fill="${thread.post.viewer?.like ? "currentColor" : "none"}" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
</svg>
<span>${thread.post.likeCount ?? 0}</span>
</span>
<span class="comments-stats-item">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" />
</svg>
<span>${thread.post.replyCount ?? 0}</span>
</span>
<span class="comments-stats-item">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
</svg>
<span>${thread.post.repostCount ?? 0}</span>
</span>
`
}
// Sort and limit visible replies
const sortedReplies = (thread.replies ?? []).sort(sortByLikes)
const visibleReplies = sortedReplies.slice(0, visibleCount)
// Render comments
listContainer.innerHTML = visibleReplies
.map(reply => renderReply(reply as AppBskyFeedDefs.ThreadViewPost, thread))
.join("")
// Add "Show More" button if there are more replies
if (sortedReplies.length > visibleCount) {
const showMoreBtn = document.createElement("button")
showMoreBtn.className = "comments-show-more"
showMoreBtn.textContent = "Show More"
showMoreBtn.onclick = () => {
visibleCount += 5
renderComments(container, thread)
}
listContainer.appendChild(showMoreBtn)
}
}
// Handle navigation events
document.addEventListener("nav", async () => {
const containers = document.querySelectorAll('[id^="comments-"]')
containers.forEach(async (container) => {
const uri = container.getAttribute("data-uri")
if (!uri) return
try {
const thread = await getPostThread(uri)
renderComments(container as HTMLElement, thread)
} catch (error) {
console.error("Failed to load comments:", error)
const listContainer = container.querySelector(".comments-list")
if (listContainer) {
listContainer.innerHTML = '<p class="text-center">Error loading comments</p>'
}
}
})
})
.comments {
&-container {
margin-top: 0.5rem;
}
&-header {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 1rem;
&:hover {
text-decoration: underline;
}
}
&-stats {
display: flex;
align-items: center;
margin-right: 0.5rem; // space between stats
&-item {
display: flex;
align-items: center;
margin-right: 0.5rem; // space between stats
svg {
width: 1rem;
height: 1rem;
}
span {
margin-left: 0.15rem;
font-size: 0.95rem;
}
}
}
&-title {
margin-top: 1rem;
font-size: 1.3rem;
font-weight: bold;
}
&-subtitle {
margin-top: 0.25rem;
font-size: 0.9rem;
a {
text-decoration: underline;
}
}
&-list {
margin-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&-comment {
margin: 0.25em 0;
font-size: 0.95rem;
&-header {
display: flex;
align-items: center;
gap: 0.25rem;
max-width: 36rem;
&:hover {
text-decoration: underline;
}
}
&-avatar {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
border-radius: 9999px;
background-color: #d1d5db;
}
&-author {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.handle {
color: #6b7280;
}
.isposter {
background-color: var(--highlight);
padding: 0.1rem 0.2rem;
border-radius: 0.25rem;
font-size: 0.8rem;
}
}
&-replies {
border-left: 1px solid #525252;
padding-left: 0.4rem;
margin-top: 0.25rem;
}
&-actions {
margin-top: 0.25rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
max-width: 120px;
opacity: 0.6;
&-item {
display: flex;
align-items: center;
gap: 0.25rem;
svg {
width: 0.9rem;
height: 0.9rem;
}
p {
font-size: 0.8rem;
}
}
}
p {
margin-block-start: 0;
margin-block-end: 0;
margin: 0;
}
}
&-more {
margin-top: 0.25rem;
font-size: 0.9rem;
color: #3b82f6;
text-decoration: underline;
}
&-divider {
margin-block-start: 0;
margin-block-end: 0;
margin: 0;
}
p {
margin-block-start: 0;
margin-block-end: 0;
margin: 0;
}
}
.author-label {
background-color: #525252;
color: white;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
font-size: 0.7rem;
margin-left: 0.3rem;
}
.comments-replies {
border-left: 1px solid #525252;
padding-left: 0.4rem;
margin-left: 0.4rem;
margin-top: 0.25rem;
}
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"
// @ts-ignore
import script from "./scripts/comments.inline"
import styles from "./styles/comments.scss"
interface Options {
uri?: string
}
const defaultOptions: Options = {}
export default ((userOpts?: Options) => {
const opts = { ...defaultOptions, ...userOpts }
const Comments = (props: QuartzComponentProps) => {
const uri = (props.fileData.frontmatter?.["bluesky-uri"] || props.fileData.frontmatter?.aturi) as string
if (!uri) return <div />
const [, , did, _, rkey] = uri.split("/")
const containerId = `comments-${rkey}`
const postUrl = `https://bsky.app/profile/${did}/post/${rkey}`
return (
<div id={containerId} data-uri={uri}>
<hr />
<div class="comments-container">
<a href={postUrl} target="_blank" class="comments-header">
<span class="comments-stats">
{/* Stats icons and counts will be populated by client-side JS */}
</span>
</a>
<h2 class="comments-title">Comments</h2>
<p class="comments-subtitle">
Reply on Bluesky{" "}
<a href={postUrl} target="_blank" rel="noreferrer noopener">
here
</a>{" "}
to join the conversation.
</p>
<hr class="comments-divider" />
<div class="comments-list">
<div>Loading comments...</div>
</div>
</div>
</div>
)
}
Comments.css = styles
Comments.afterDOMLoaded = script
return Comments
}) satisfies QuartzComponentConstructor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment