diff --git a/docs/features/comments.md b/docs/features/comments.md index 6e5a25ca1..b73ac4646 100644 --- a/docs/features/comments.md +++ b/docs/features/comments.md @@ -8,7 +8,7 @@ Quartz also has the ability to hook into various providers to enable readers to ![[giscus-example.png]] -As of today, only [Giscus](https://giscus.app/) is supported out of the box but PRs to support other providers are welcome! +As of today, [Giscus](https://giscus.app/) and [Bluesky](https://bsky.app/) are supported out of the box, and PRs to support other providers are welcome! ## Providers @@ -52,6 +52,31 @@ afterBody: [ ], ``` +### Bluesky + +Bluesky comments allow you to pull a conversation from a [Bluesky](https://bsky.app) post directly into your Quartz pages. + +#### Setup + +1. Add `Component.BlueskyComments()` to the `afterBody` field of `sharedPageComponents` in `quartz.layout.ts`: + +```ts title="quartz.layout.ts" +afterBody: [ + Component.BlueskyComments(), +], +``` + +1. On any page where you want comments to appear, add the `blueskyUrl` field to your frontmatter pointing to the Bluesky post: + +```markdown +--- +title: My Cool Post +blueskyUrl: https://bsky.app/profile/jacky.zys.xyz/post/3lf... +--- +``` + +The component will automatically resolve the thread and render the replies recursively. + ### Customization Quartz also exposes a few of the other Giscus options as well and you can provide them the same way `repo`, `repoId`, `category`, and `categoryId` are provided. @@ -125,7 +150,7 @@ afterBody: [ Quartz can conditionally display the comment box based on a field `comments` in the frontmatter. By default, all pages will display comments, to disable it for a specific page, set `comments` to `false`. -``` +```markdown --- title: Comments disabled here! comments: false diff --git a/quartz.layout.ts b/quartz.layout.ts index 970a5be34..13a1e191f 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -5,7 +5,9 @@ import * as Component from "./quartz/components" export const sharedPageComponents: SharedLayout = { head: Component.Head(), header: [], - afterBody: [], + afterBody: [ + Component.BlueskyComments(), + ], footer: Component.Footer({ links: { GitHub: "https://github.com/jackyzha0/quartz", diff --git a/quartz/components/BlueskyComments.tsx b/quartz/components/BlueskyComments.tsx new file mode 100644 index 000000000..ce6a3b989 --- /dev/null +++ b/quartz/components/BlueskyComments.tsx @@ -0,0 +1,30 @@ + +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" +import { classNames } from "../util/lang" +// @ts-ignore +import script from "./scripts/bluesky.inline" +import style from "./styles/bluesky.scss" + +const BlueskyComments: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { + const blueskyUrl = fileData.frontmatter?.blueskyUrl as string | undefined + if (!blueskyUrl) { + return null + } + + return ( +
+

Comments

+

+ Post a reply on Bluesky to join the conversation. +

+
+

Loading comments...

+
+
+ ) +} + +BlueskyComments.afterDOMLoaded = script +BlueskyComments.css = style + +export default (() => BlueskyComments) satisfies QuartzComponentConstructor diff --git a/quartz/components/index.ts b/quartz/components/index.ts index cece8e614..344f67f84 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -23,6 +23,7 @@ import Breadcrumbs from "./Breadcrumbs" import Comments from "./Comments" import Flex from "./Flex" import ConditionalRender from "./ConditionalRender" +import BlueskyComments from "./BlueskyComments" export { ArticleTitle, @@ -50,4 +51,5 @@ export { Comments, Flex, ConditionalRender, + BlueskyComments, } diff --git a/quartz/components/scripts/bluesky.inline.ts b/quartz/components/scripts/bluesky.inline.ts new file mode 100644 index 000000000..e3f1218e9 --- /dev/null +++ b/quartz/components/scripts/bluesky.inline.ts @@ -0,0 +1,199 @@ + +document.addEventListener("nav", async () => { + const container = document.getElementById("bluesky-comments") + if (!container) return + + const url = container.getAttribute("data-url") + if (!url) return + + try { + const { handle, postId } = parseBlueskyUrl(url) + const did = await resolveHandle(handle) + const replies = await fetchThread(did, postId) + renderComments(replies) + } catch (e) { + console.error("Error loading Bluesky comments:", e) + const list = document.getElementById("bluesky-comments-list") + if (list) { + list.textContent = "Error loading comments." + } + } +}) + +function parseBlueskyUrl(url: string) { + const urlParts = new URL(url).pathname.split("/") + const handle = urlParts[2] + const postId = urlParts[4] + + if (!handle || !postId) throw new Error("Invalid Bluesky URL") + return { handle, postId } +} + +async function resolveHandle(handle: string) { + const resolveRes = await fetch( + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}` + ) + if (!resolveRes.ok) throw new Error("Could not resolve handle") + const { did } = await resolveRes.json() + return did +} + +async function fetchThread(did: string, postId: string) { + const atUri = `at://${did}/app.bsky.feed.post/${postId}` + const threadRes = await fetch( + `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${atUri}&depth=10&parentHeight=0` + ) + + if (!threadRes.ok) throw new Error("Could not fetch thread") + const data = await threadRes.json() + return data.thread.replies +} + +function renderComments(replies: any[]) { + const commentsList = document.getElementById("bluesky-comments-list") + if (!commentsList) return + + if (replies && replies.length > 0) { + commentsList.innerHTML = "" // Clear loading text + // Sort by likes + replies.sort((a: any, b: any) => + (b.post.likeCount || 0) - (a.post.likeCount || 0) + ) + + replies.forEach((reply: any) => { + if (!reply.post) return + commentsList.appendChild(createCommentNode(reply)) + }) + } else { + commentsList.textContent = "No comments yet." + } +} + +function createCommentNode(reply: any): HTMLElement { + const post = reply.post + const author = post.author + const date = new Date(post.indexedAt).toLocaleDateString() + + const div = document.createElement("div") + div.className = "comment" + + // Header + const header = document.createElement("div") + header.className = "comment-header" + + if (author.avatar) { + const avatar = document.createElement("img") + avatar.src = author.avatar + avatar.alt = author.handle + header.appendChild(avatar) + } + + const handleLink = document.createElement("a") + const postId = post.uri.split("/").pop() + handleLink.href = `https://bsky.app/profile/${author.did}/post/${postId}` + handleLink.target = "_blank" + handleLink.className = "handle" + handleLink.textContent = author.displayName || author.handle + header.appendChild(handleLink) + + const timeSpan = document.createElement("span") + timeSpan.className = "time" + timeSpan.textContent = date + header.appendChild(timeSpan) + + div.appendChild(header) + + // Body + div.appendChild(renderPostBody(post)) + + // Actions + const actions = document.createElement("div") + actions.className = "comment-actions" + + const likes = document.createElement("span") + likes.textContent = `❤️ ${post.likeCount || 0}` + actions.appendChild(likes) + + const reposts = document.createElement("span") + reposts.textContent = `🔁 ${post.repostCount || 0}` + actions.appendChild(reposts) + + div.appendChild(actions) + + // Recursive rendering for nested replies + if (reply.replies && reply.replies.length > 0) { + const repliesContainer = document.createElement("div") + repliesContainer.className = "replies" + reply.replies.forEach((child: any) => { + if (!child.post) return + repliesContainer.appendChild(createCommentNode(child)) + }) + div.appendChild(repliesContainer) + } + + return div +} +function renderPostBody(post: any): HTMLElement { + const text = post.record.text + const facets = post.record.facets + const body = document.createElement("div") + body.className = "comment-body" + + if (!facets || facets.length === 0) { + body.textContent = text + return body + } + + facets.sort((a: any, b: any) => a.index.byteStart - b.index.byteStart) + + const bytes = new TextEncoder().encode(text) + let lastByteIndex = 0 + + for (const facet of facets) { + const { byteStart, byteEnd } = facet.index + + if (byteStart > lastByteIndex) { + const slice = bytes.slice(lastByteIndex, byteStart) + const textNode = document.createTextNode(new TextDecoder().decode(slice)) + body.appendChild(textNode) + } + + const slice = bytes.slice(byteStart, byteEnd) + const facetText = new TextDecoder().decode(slice) + const feature = facet.features[0] + + if (feature["$type"] === "app.bsky.richtext.facet#link") { + const link = document.createElement("a") + link.href = feature.uri + link.target = "_blank" + link.rel = "noopener noreferrer" + link.textContent = facetText + body.appendChild(link) + } else if (feature["$type"] === "app.bsky.richtext.facet#mention") { + const link = document.createElement("a") + link.href = `https://bsky.app/profile/${feature.did}` + link.target = "_blank" + link.rel = "noopener noreferrer" + link.textContent = facetText + body.appendChild(link) + } else if (feature["$type"] === "app.bsky.richtext.facet#tag") { + const link = document.createElement("a") + link.href = `https://bsky.app/hashtag/${feature.tag}` + link.target = "_blank" + link.rel = "noopener noreferrer" + link.textContent = facetText + body.appendChild(link) + } else { + body.appendChild(document.createTextNode(facetText)) + } + + lastByteIndex = byteEnd + } + + if (lastByteIndex < bytes.length) { + const slice = bytes.slice(lastByteIndex) + body.appendChild(document.createTextNode(new TextDecoder().decode(slice))) + } + + return body +} diff --git a/quartz/components/styles/bluesky.scss b/quartz/components/styles/bluesky.scss new file mode 100644 index 000000000..e587761d2 --- /dev/null +++ b/quartz/components/styles/bluesky.scss @@ -0,0 +1,77 @@ +.bluesky-comments-container { + margin-top: 4rem; + padding-top: 2rem; + border-top: 1px solid var(--lightgray); + + h2 { + margin-bottom: 1rem; + } + + .bluesky-meta { + margin-bottom: 1.5rem; + color: var(--gray); + font-size: 0.9rem; + + a { + color: var(--secondary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .comment { + margin-top: 1rem; + padding-left: 1rem; + border-left: 2px solid var(--lightgray); + + .comment-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + + img { + width: 24px; + height: 24px; + border-radius: 50%; + } + + .handle { + font-weight: bold; + color: var(--dark); + font-size: 0.9rem; + } + + .time { + color: var(--gray); + font-size: 0.8rem; + } + } + + .comment-body { + font-size: 0.95rem; + color: var(--darkgray); + white-space: pre-wrap; + + a { + color: var(--secondary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .comment-actions { + margin-top: 0.25rem; + font-size: 0.8rem; + color: var(--gray); + display: flex; + gap: 1rem; + } + } +} \ No newline at end of file