Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

Blog comments via Mastodon

For quite a while, I've wanted to enable some way to comment on my blog posts as that can provide a great way to get more opinions, points of views and corrections to the content I've written. I didn't want people to need to make a new account to a new service just to write a comment.

Over the time, I've run into many great stories of people building something similar. In December, Yusuf Bouzekri shared how he added webmentions to his blog. In January, Andy Bell shared improvements he made for the "likes" section in his blog. Then there was Jan Wildeboer and his Mastodon-based approach and finally this week in July, Cassidy James' story of him implementing comments based on Jan's work finally gave me the push needed.

Since writing this originally, I've also found that Carl Schwan and Veronica Olsen have implemented Mastodon comments to their blogs in the past as well.

My approach

I decided to start with a basic approach and then I'll iterate upon it over time as I see what I like and what not.

I use Eleventy and Ghost CMS to run my blog. When I want to enable comments on my blog post, I first write it and publish it, then toot about it in the fediverse. Once that is done, I can capture the Mastodon post ID and add that to my post's front matter:

---
# other front matter first, then
mastodon_id: "110740556042408938"
---

On build time, I inject this ID into the HTML for in the blog post page:


    <section id="comment-section">
      <h2>Comments</h2>
      <div class="comment-ingress">
        Comment by replying to <a href="https://mastodon.world/@hamatti/110779197470760541">this post in Mastodon</a>.
      </div>
      <div id="comments" data-id="110779197470760541"">
        <p>Loading comments...</p>
      </div>
      <div class="continue-discussion">
        <p>Continue discussion in <a href="https://mastodon.world/@hamatti/110779197470760541">Mastodon</a> &#187;</p>
    </section>

For this first version, I ended up hard coding my account information and just storing the ID of the individual post. This is different from Jan's approach in which he always put the host, the account and the ID into the post's front matter. I might implement that at some point to get a bit more flexibility.

I also added a <template> tag for an individual comment:

<template id="comment-template">
  <article class="blog-comment">
    <div>
      <img />
    </div>
    <div class="comment-content">
      <div>
        <div class="author"></div>
        <div class="publish-date"></div>
      </div>
      <div class="comment"></div>
    </div>
  </article>
</template>

On the runtime, I then check if there is a Mastodon ID in the data-id attribute and if there is, I do two calls to the Mastodon API: one to https://[DOMAIN]/api/v1/statuses/[MASTODON_ID] to get the original post and another to https://[DOMAIN]/api/v1/statuses/[MASTODON_ID]/context to get the replies.

For each comment from these, I then copy the template, fill it in and render that to the page.

Code Snippets

I was asked if I could provide more complete code snippets for this so here they are. You may have to adjust some of these based on your stack.

Blog post front matter

I have this line in my front matter:

---
mastodon_id: "[your post id]"
---

The [your post id] comes from the Mastodon post URL. For example, the id for this introduction post: https://mastodon.world/@hamatti/110808657202754329 is 110808657202754329.

Post template

On build-time, I use this template to inject the Mastodon post id into the HTML element:

{% if mastodon_id %}
  <section id="comment-section">
    <h2>Comments</h2>
    <div class="comment-ingress">
      Comment by replying to <a href="https://mastodon.world/@hamatti/{{ mastodon_id }}">this post in Mastodon</a>.
    </div>
    <div id="comments" data-id="{{mastodon_id}}">
      <p>Loading comments...</p>
    </div>
    <div class="continue-discussion">
      <p>Continue discussion in <a href="https://mastodon.world/@hamatti/{{ mastodon_id }}">Mastodon</a> »</p>
    </div>
  </section>
{% endif %}

The entire block is wrapped in if clause so it's only rendered if I've added the Mastodon id to front matter. On line 7, I add it as a data attribute so it's accessible via Javascript.

Client-side Javascript

Final part of the puzzle is fetching the replies from Mastodon API and showing them after the post. On lines 51 and 57, you need to switch the base URL to match your instance.

function renderComment(comment, target, parentId) {
  const node = document
    .querySelector("template#comment-template")
    .content.cloneNode(true);

  const author = node.querySelector(".author");
  let mastodonAcct = comment.account.acct;
  if (mastodonAcct === "hamatti") {
    mastodonAcct = "hamatti@mastodon.world";
  }

  let in_reply_to_id = comment.in_reply_to_id;

  author.innerHTML = `${comment.account.display_name} (${mastodonAcct})`;

  const commentContainer = node.querySelector(".blog-comment");
  if (in_reply_to_id !== parentId) {
    commentContainer.classList.add("indent");
  }

  const publishDate = node.querySelector(".publish-date");
  const dateObj = new Date(comment.created_at);

  const dateTime = `${dateObj.getDate()}.${
    dateObj.getMonth() + 1
  }.${dateObj.getFullYear()} ${dateObj.getHours()}:${dateObj.getMinutes()}`;

  publishDate.innerHTML = `${dateTime}`;

  const userComment = node.querySelector(".comment");
  userComment.innerHTML = comment.content;

  const avatar = node.querySelector("img");
  avatar.src = comment.account.avatar_static;

  target.appendChild(node);
}

async function renderComments() {
  const commentsNode = document.querySelector("#comments");

  const mastodonPostId = commentsNode.dataset?.id;

  if (!mastodonPostId) {
    return;
  }

  commentsNode.innerHTML = "";

  const originalPost = await fetch(
    `https://mastodon.world/api/v1/statuses/${mastodonPostId}`
  );
  const originalData = await originalPost.json();
  renderComment(originalData, commentsNode, null);

  const response = await fetch(
    `https://mastodon.world/api/v1/statuses/${mastodonPostId}/context`
  );
  const data = await response.json();
  const comments = data.descendants;

  comments.forEach((comment) => {
    renderComment(comment, commentsNode, mastodonPostId);
  });
}

renderComments();

with the <template> that is used as a base (this is in my post layout):

<template id="comment-template">
  <article class="blog-comment">
    <div class="comment-meta">
      <div>
        <img/>
      </div>
      <div class="comment-meta-text">
        <div class="author"></div>
        <div class="publish-date"></div>
      </div>
    </div>
    <div class="comment-content">
      <div class="comment"></div>
    </div>
  </article>
</template>

Considerations

I'm pretty happy by how this turned out and how I was able to build most of it in a short time while sipping a drink in a local pub. I love these kind of small projects to get started with something and then improving upon it over time.

Mastodon approach still requires people to have an account to comment but I hope it's gonna be a good tradeoff since a lot of my audience already is in Fediverse. And maybe this will encourage others to join.

Comments

Comment by replying to this post in Mastodon.

Loading comments...

Continue discussion in Mastodon »

Syntax Error

Sign up for Syntax Error, a monthly newsletter that helps developers turn a stressful debugging situation into a joyful exploration.