diff --git a/app/blog/entry.js b/app/blog/entry.js index 10492421f7c..6a205e9e97d 100644 --- a/app/blog/entry.js +++ b/app/blog/entry.js @@ -38,6 +38,7 @@ module.exports = function (request, response, next) { ) { delete blog.plugins.commento; delete blog.plugins.disqus; + delete blog.plugins.blueskyComments; } // Redirect this entry to the file from which it was generated @@ -83,7 +84,7 @@ module.exports = function (request, response, next) { // Don't show plugin HTML on a preview subdomain. // This is to prevent Disqus getting stuck on one URL. if (entry.draft || request.preview) { - pluginHTML = ""; + pluginHTML = !!pluginHTML ? "

Comments are hidden on site previews.

" : ''; } response.locals.partials.pluginHTML = pluginHTML; diff --git a/app/build/plugins/blueskyComments/entry.html b/app/build/plugins/blueskyComments/entry.html new file mode 100644 index 00000000000..bb4c622eeb6 --- /dev/null +++ b/app/build/plugins/blueskyComments/entry.html @@ -0,0 +1,58 @@ +
+

Comments

+
+ + + + + + + + + + + + + diff --git a/app/build/plugins/blueskyComments/form.html b/app/build/plugins/blueskyComments/form.html new file mode 100644 index 00000000000..14ebf4a7e35 --- /dev/null +++ b/app/build/plugins/blueskyComments/form.html @@ -0,0 +1,6 @@ + +

Your Bluesky profile URL (e.g., https://bsky.app/profile/example.bsky.social). This will be used to automatically find your posts linking to each page when no Bluesky metadata is set on the entry.

+ diff --git a/app/build/plugins/blueskyComments/index.js b/app/build/plugins/blueskyComments/index.js new file mode 100644 index 00000000000..4a4f6149adb --- /dev/null +++ b/app/build/plugins/blueskyComments/index.js @@ -0,0 +1,8 @@ +module.exports = { + description: "Bluesky comments", + category: "external", + isDefault: false, + options: { + authorURI: "", + }, +}; diff --git a/app/build/plugins/blueskyComments/public.css b/app/build/plugins/blueskyComments/public.css new file mode 100644 index 00000000000..23e4d814062 --- /dev/null +++ b/app/build/plugins/blueskyComments/public.css @@ -0,0 +1,83 @@ +.comment-container { + display: flex; +} + +.comment-container > a { + flex-shrink: 0; +} + +/* Post Actions */ +.comment-container a, +#main-post-actions { + text-decoration: none; + color: var(--highlight2); +} + +.post-actions { + display: flex; + gap: 1em; +} + +.post-actions svg { + width: 12px; + height: 12px; +} + +.post-actions div svg, +.post-actions div span { + vertical-align: middle; + display: inline-block; +} + +.post-actions div:hover { + color: var(--accent); +} + +.post-actions div:hover svg { + stroke: var(--accent); +} + +/* Comment Section */ +#comments .comment { + position: relative; /* Needed for pseudo-element positioning */ +} + +#comments .comment .comment-container { + margin-bottom: 24px; +} + +#comments .comment-container img { + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 16px; +} + +#comments .comment .comment-container p { + color: var(--accent); + margin: 0.25em 0; +} + +#comments .comment .replies { + padding-left: calc(32px + 16px); +} + +#comments #see-more { + position: absolute; + left: 50%; + transform: translate(-50%); + padding: 24px; +} + +.comment:has(.replies)::after { + content: ""; + position: absolute; + top: calc(32px + 0.5em); + left: 16px; + width: 20px; + height: calc(100% - 41px - 0.5em); + border: 2px solid var(--highlight); + border-top: none; + border-right: none; + border-radius: 0 0 0 20px; +} diff --git a/app/build/plugins/blueskyComments/public.js b/app/build/plugins/blueskyComments/public.js new file mode 100644 index 00000000000..d7c8def0882 --- /dev/null +++ b/app/build/plugins/blueskyComments/public.js @@ -0,0 +1,237 @@ +const convertToAtUri = (url) => { + const match = url.match( + /https:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/]+)/ + ); + if (!match) throw new Error("Invalid Bluesky URL format."); + const did = match[1]; + const rkey = match[2]; + return `at://${did}/app.bsky.feed.post/${rkey}`; +}; + +const extractHandleFromProfileUrl = (profileUrl) => { + const match = profileUrl.match(/https?:\/\/bsky\.app\/profile\/([^/?]+)/); + if (match) { + return match[1]; + } + throw new Error("Invalid Bluesky profile URL format."); +}; + +const getRkeyFromUri = (uri) => { + return uri.split("/").pop(); +}; + +const getPostUrl = (authorDid, rkey) => { + return `https://bsky.app/profile/${authorDid}/post/${rkey}`; +}; + +const sortByLikeCount = (a, b) => (b.post.likeCount || 0) - (a.post.likeCount || 0); + +const getTemplate = (id) => { + const template = document.getElementById(id); + if (!template) throw new Error(`Template ${id} not found`); + return template.content.cloneNode(true); +}; + +const renderPostActions = (post) => { + const fragment = getTemplate("template-post-actions"); + fragment.querySelector("[data-like-count]").textContent = post.likeCount || 0; + fragment.querySelector("[data-repost-count]").textContent = post.repostCount || 0; + fragment.querySelector("[data-reply-count]").textContent = post.replyCount || 0; + return fragment; +}; + +const renderCommentContainer = (post) => { + const author = post.author; + const rkey = getRkeyFromUri(post.uri); + const postUrl = getPostUrl(author.did, rkey); + + const fragment = getTemplate("template-comment-container"); + const links = fragment.querySelectorAll("[data-post-url]"); + links.forEach(link => { + link.href = postUrl; + }); + fragment.querySelector("[data-avatar]").src = author.avatar; + fragment.querySelector("[data-avatar]").alt = `${author.displayName}'s avatar`; + fragment.querySelector("[data-display-name]").textContent = author.displayName; + fragment.querySelector("[data-text]").textContent = post.record?.text || ""; + + // Add post actions + const actionsContainer = fragment.querySelector(".comment-container > div"); + actionsContainer.appendChild(renderPostActions(post)); + + return fragment; +}; + +const renderComment = (post) => { + const fragment = getTemplate("template-comment"); + const containerPlaceholder = fragment.querySelector("[data-comment-container]"); + const commentContainer = renderCommentContainer(post); + containerPlaceholder.replaceWith(commentContainer.querySelector(".comment-container")); + return fragment; +}; + +const renderThread = (thread) => { + const fragment = renderComment(thread.post); + const repliesContainer = fragment.querySelector("[data-replies]"); + + const replies = thread.replies + .sort(sortByLikeCount) + .slice(0, 3) + .map((reply) => renderComment(reply.post)); + + if (replies.length > 0) { + replies.forEach(reply => { + repliesContainer.appendChild(reply); + }); + } else { + repliesContainer.remove(); + } + + return fragment; +}; + +const renderCommentsHeader = (postUrl) => { + const fragment = getTemplate("template-comments-header"); + fragment.querySelector("[data-post-url]").href = postUrl; + return fragment; +}; + +const renderErrorMessage = (message) => { + const fragment = getTemplate("template-error-message"); + fragment.querySelector("[data-message]").textContent = message; + return fragment; +}; + +const renderReplyLink = (postUrl) => { + const fragment = getTemplate("template-reply-link"); + fragment.querySelector("[data-post-url]").href = postUrl; + return fragment; +}; + +const loadThread = (uri, container, originalUrl) => { + fetch( + `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent( + uri + )}`, + { + method: "GET", + headers: { Accept: "application/json" }, + } + ) + .then(async (response) => { + if (!response.ok) throw new Error(await response.text()); + + const { thread } = await response.json(); + const replies = (thread.replies || []).sort(sortByLikeCount); + + // Add main post actions if element exists + const mainPostActions = document.getElementById("main-post-actions"); + if (mainPostActions) { + mainPostActions.innerHTML = ""; + mainPostActions.appendChild(renderPostActions(thread.post)); + } + + // Append top 25 comments + replies.slice(0, 25).forEach(reply => { + container.appendChild(renderThread(reply)); + }); + + // Add "Show More" button if there are more comments + if (replies.length > 25 && !container.querySelector("#see-more")) { + const postUrl = + originalUrl || + getPostUrl(thread.post.author.did, getRkeyFromUri(uri)); + container.appendChild(renderReplyLink(postUrl)); + } + }) + .catch((error) => { + console.error("Error loading thread:", error); + container.innerHTML = ""; + container.appendChild(renderErrorMessage("Error loading comments.")); + }); +}; + +const init = () => { + const container = document.getElementById("comments"); + + if (!container) return; + + const dataUri = container.dataset.uri?.trim(); + const dataAuthor = container.dataset.author?.trim(); + + // Priority 1: Use Bluesky metadata if set (non-empty URL) + if (dataUri) { + const uri = convertToAtUri(dataUri); + // Add initial content for metadata case + const replyLink = document.createElement("p"); + const link = document.createElement("a"); + link.href = dataUri; + link.target = "_blank"; + link.textContent = "Reply on Bluesky"; + replyLink.appendChild(link); + container.appendChild(replyLink); + loadThread(uri, container, dataUri); + } + // Priority 2: Auto-discover using author search (non-empty URL) + else if (dataAuthor) { + // Add loading message + const loadingMsg = document.createElement("p"); + loadingMsg.textContent = "Loading comments..."; + container.appendChild(loadingMsg); + + const author = extractHandleFromProfileUrl(dataAuthor); + const fetchPost = async () => { + const currentUrl = window.location.href; + const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${encodeURIComponent( + currentUrl + )}&author=${encodeURIComponent(author)}&sort=top`; + + try { + const response = await fetch(apiUrl); + const data = await response.json(); + + if (data.posts && data.posts.length > 0) { + const post = data.posts[0]; + const uri = post.uri; + const rkey = getRkeyFromUri(uri); + const postUrl = getPostUrl(post.author.did, rkey); + + // Update container with comments header + container.innerHTML = ""; + container.appendChild(renderCommentsHeader(postUrl)); + + // Add main post actions element + const mainPostActions = document.createElement("a"); + mainPostActions.id = "main-post-actions"; + mainPostActions.target = "_blank"; + mainPostActions.href = postUrl; + container.appendChild(mainPostActions); + + // Load the thread + loadThread(uri, container, postUrl); + } else { + container.innerHTML = ""; + container.appendChild(renderErrorMessage( + "No Bluesky post found for this page." + )); + } + } catch (err) { + console.error("Error fetching post:", err); + container.innerHTML = ""; + container.appendChild(renderErrorMessage( + "Error searching for Bluesky post." + )); + } + }; + + fetchPost(); + } + // Priority 3: Show error message if neither is configured + else { + const errorMsg = document.createElement("p"); + errorMsg.innerHTML = `Bluesky comments are not configured. Please set a Bluesky metadata field on this entry, or configure your Bluesky author handle in the plugin settings.`; + container.appendChild(errorMsg); + } +}; + +init(); diff --git a/app/build/plugins/index.js b/app/build/plugins/index.js index 8e712750aa5..dddc7911fcd 100644 --- a/app/build/plugins/index.js +++ b/app/build/plugins/index.js @@ -22,6 +22,7 @@ var loaded = loadPlugins({ analytics: require("./analytics"), autoImage: require("./autoImage"), bluesky: require("./bluesky"), + blueskyComments: require("./blueskyComments"), codeHighlighting: require("./codeHighlighting"), commento: require("./commento"), disqus: require("./disqus"), diff --git a/app/views/how/configure/bluesky-comments.html b/app/views/how/configure/bluesky-comments.html new file mode 100644 index 00000000000..092b5004bc7 --- /dev/null +++ b/app/views/how/configure/bluesky-comments.html @@ -0,0 +1,33 @@ +

Bluesky comments

+ +

+ Blot’s Bluesky comments plugin lets you embed the discussion for a Bluesky + post at the end of any entry. The comments are rendered by the + Bluesky comments widget + and require the identifier of the Bluesky post you want to attach. +

+ +
    +
  1. Open the Services page and enable Bluesky comments.
  2. +
  3. + Enter the default Bluesky handle you would like to use when loading the widget. + This should usually be the author of the blog or the account responsible for the + linked discussion. +
  4. +
  5. + For every post that should show Bluesky comments, include a + Bluesky field in the entry’s metadata set to the full post URI + (for example at://did:plc:example/app.bsky.feed.post/123). +
  6. +
+ +

+ Without the Bluesky metadata field the widget will not appear, even if the + plugin is enabled. Comments remain hidden on pages unless you explicitly set + Comments: Yes in the page metadata. +

+ +

+ You can combine the plugin with other comment systems and switch between them + whenever you need from the Services page. +

diff --git a/app/views/how/configure/comments.html b/app/views/how/configure/comments.html index f3807106e2c..50d7300ad61 100644 --- a/app/views/how/configure/comments.html +++ b/app/views/how/configure/comments.html @@ -13,8 +13,20 @@

Comments

Disqus +
  • + + Bluesky comments +
  • +

    + See the Bluesky comments guide + for details on how to attach a Bluesky thread to a post. Each entry needs a + Bluesky metadata field pointing to the post’s AT URI for the + widget to appear. +

    +

    Controlling comments on individual entries

    When enabled, comments are shown on posts and are not shown on pages. You can diff --git a/app/views/tools/comments/bluesky.html b/app/views/tools/comments/bluesky.html new file mode 100644 index 00000000000..d2c10cfc15b --- /dev/null +++ b/app/views/tools/comments/bluesky.html @@ -0,0 +1,12 @@ +--- +Link: https://bsky.app/ +--- + +

    Bluesky comments

    + +

    + Embed Bluesky discussion threads at the end of your posts. Enable the plugin on + the Services page, add your default Bluesky + handle, and include a Bluesky metadata field with the AT URI of the + post you want to attach. +

    diff --git a/app/views/tools/icons/bluesky.png b/app/views/tools/icons/bluesky.png new file mode 100644 index 00000000000..09393a74131 Binary files /dev/null and b/app/views/tools/icons/bluesky.png differ