Comment systems are an essential part of modern websites, providing a platform for users to engage with the content and share their thoughts and opinions. While there are many options available for adding commenting functionality to a website, one alternative worth considering is integrating a Mastodon thread, also known as a “toot.”

Mastodon is an open-source, decentralized social media platform that allows users to share text, images, and other types of media in a manner similar to Twitter.

In this blog post, we will explore how to integrate a Mastodon thread into your static website as a way to replace traditional comment systems, using simple HTML and JavaScript. By the end of this tutorial, you will have a fully functional Mastodon thread on your website that allows users to participate in discussions and share their thoughts and ideas.

The Fediverse — Mastodon

Mastodon

Twitter is a centralized social media platform that allows users to share short messages, called “tweets,” along with images, videos, and other types of media. Twitter is owned by a private company and has a centralized structure, which means that all user data and content is stored on servers owned and operated by the company.

In contrast, Mastodon is a decentralized social media platform that is part of the Fediverse. The Fediverse is a network of interconnected, decentralized social media platforms that use the same open-source software and protocols, allowing users on different platforms to communicate with each other. Mastodon operates on a decentralized model, meaning that it is not owned or controlled by a single entity and user data and content is stored on servers run by individual users or organizations. This decentralized model allows for greater privacy and control over user data, as well as more diverse and independent communities.

In terms of functionality, Mastodon is similar to Twitter in that it allows users to share text, images, and other types of media in short messages called “toots.” However, Mastodon also has additional features, such as the ability to boost (similar to retweeting) and favorite toots, as well as the ability to customize your profile and the look and feel of your Mastodon instance (server).

Disqus

Using a third party commenting system like Disqus does involve some trade-offs, including the fact that you are giving up some control over your user data. When you use a third-party system like Disqus, the user data (such as comments, email addresses, and any personal information provided by users) is stored on servers owned and operated by Disqus. This means that you do not have direct control over this data, and you will have to rely on Disqus to properly secure and manage it.

There are a few potential disadvantages to not owning your user data when using a third party commenting system. First, you may be subject to the privacy policies and data handling practices of the third-party system, which may not align with your own policies or values. This can be especially concerning if you are collecting sensitive or personal information from your users, as you may not have control over how this information is used or shared.

Second, not owning your user data can also make it more difficult to migrate to a different commenting system or platform in the future. If you decide to switch to a different system, you may have to manually transfer all the user data from the third-party system to the new platform, which can be time-consuming and may not be possible in all cases.

Finally, using a third party commenting system can also potentially result in lost traffic to your website, as users may be directed to the third-party system’s website when they leave comments or log in to their accounts. This can be especially problematic if you are relying on advertising or other monetization methods that rely on website traffic.

Integrating a Mastodon Toot thread into your blog post

There are several potential advantages to integrating a blog post with the Fediverse, specifically Mastodon:

  1. Increased visibility: By integrating your blog with Mastodon, you can potentially increase the visibility of your content by reaching a wider audience. Mastodon has a large and active user base, and by publishing your content on the platform, you can expose it to a new audience that may not have seen it otherwise.

  2. Greater control over user data: Mastodon operates on a decentralized model, which means that user data is stored on servers run by individual users or organizations. This can give you greater control over your user data and how it is used, compared to using a third party commenting system like Disqus.

  3. Improved user experience: Mastodon allows users to easily share and engage with your content, as they can simply “boost” (similar to retweeting) your toots to share them with their followers. This can create a more interactive and engaging experience for your readers.

  4. Enhanced privacy: Mastodon has a strong focus on privacy and user control, which can be appealing to users who are concerned about the data handling practices of centralized platforms. By integrating with Mastodon, you can offer a more private and secure commenting system for your readers.

  5. Stronger sense of community: Mastodon is known for fostering strong and independent communities, and by integrating your blog with the platform, you can potentially create a more cohesive and engaged community around your content.

The technical proposal is to have a specific toot thread per blog post, so that a Mastodon user would be able to post replies to your post in the thread itself, as any other toot you may publish in the Fediverse itself. The “only” thing left is to integrate that thread into your blog, as if those were comments from a classic system.

How? Thanks to Carl Schwan (@carlschwan@floss.social) who shared in a blogpost the Javscript code necessary to achieve this: https://carlschwan.eu/2020/12/29/adding-comments-to-your-static-blog-with-mastodon/

This is the code that I finally ended using, heavily based on Carl’s. The main differences are:

  • I’m importing the necessary libs from CloudFare’s CDN, not hosting those files directly
  • Toot is automatically loaded as soon as the user scrolls to that section of the page
  • and of course, the parameter names (which are based on Hugo and my modified hugo-tranquilpeak-theme)
Hugo

I’ve refactored the code in this article into a standalone webcomponent, easy to use and integrate. Find the code in the following GitHub repository: mastodon-comments

<h2>Comments</h2>

<noscript>
  <div id="error">
    Please enable JavaScript to view the comments powered by the Fediverse.
  </div>
</noscript>

<p>You can use your Fediverse (i.e. Mastodon, among many others) account to reply to this <a class="link"
    href="https://{{ .Site.Params.comment.fediverse.host }}/@{{ .Site.Params.comment.fediverse.user }}/{{ .Params.fediverse }}">post</a>.
</p>
<p id="mastodon-comments-list"></p>

<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.1/purify.min.js" integrity="sha512-uHOKtSfJWScGmyyFr2O2+efpDx2nhwHU2v7MVeptzZoiC7bdF6Ny/CmZhN2AwIK1oCFiVQQ5DA/L9FSzyPNu6Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script type="text/javascript">
  var host = '{{ .Site.Params.comment.fediverse.host }}';
  var user = '{{ .Site.Params.comment.fediverse.user }}';
  var id = '{{ .Params.fediverse }}'

  function escapeHtml(unsafe) {
    return unsafe
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#039;");
  }

  var commentsLoaded = false;

  function toot_active(toot, what) {
    var count = toot[what+'_count'];
    return count > 0 ? 'active' : '';
  }

  function toot_count(toot, what) {
    var count = toot[what+'_count'];
    return count > 0 ? count : '';
  }

  function user_account(account) {
    var result =`@${account.acct}`;
    if (account.acct.indexOf('@') === -1) {
      var domain = new URL(account.url)
      result += `@${domain.hostname}`
    }
    return result;
  }

  function render_toots(toots, in_reply_to, depth) {
    var tootsToRender = toots
      .filter(toot => toot.in_reply_to_id === in_reply_to)
      .sort((a, b) => a.created_at.localeCompare(b.created_at));
    tootsToRender.forEach(toot => render_toot(toots, toot, depth));
  }

  function render_toot(toots, toot, depth) {
    toot.account.display_name = escapeHtml(toot.account.display_name);
    toot.account.emojis.forEach(emoji => {
      toot.account.display_name = toot.account.display_name.replace(`:${emoji.shortcode}:`, `<img src="${escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode}" height="20" width="20" />`);
    });
    mastodonComment =
      `<div class="mastodon-comment" style="margin-left: calc(var(--mastodon-comment-indent) * ${depth})">
        <div class="author">
          <div class="avatar">
            <img src="${escapeHtml(toot.account.avatar_static)}" height=60 width=60 alt="">
          </div>
          <div class="details">
            <a class="name" href="${toot.account.url}" rel="nofollow">${toot.account.display_name}</a>
            <a class="user" href="${toot.account.url}" rel="nofollow">${user_account(toot.account)}</a>
          </div>
          <a class="date" href="${toot.url}" rel="nofollow">${toot.created_at.substr(0, 10)} ${toot.created_at.substr(11, 8)}</a>
        </div>
        <div class="content">${toot.content}</div>
        <div class="attachments">
          ${toot.media_attachments.map(attachment => {
            if (attachment.type === 'image') {
              return `<a href="${attachment.url}" rel="nofollow"><img src="${attachment.preview_url}" alt="${attachment.description}" /></a>`;
            } else if (attachment.type === 'video') {
              return `<video controls><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
            } else if (attachment.type === 'gifv') {
              return `<video autoplay loop muted playsinline><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
            } else if (attachment.type === 'audio') {
              return `<audio controls><source src="${attachment.url}" type="${attachment.mime_type}"></audio>`;
            } else {
              return `<a href="${attachment.url}" rel="nofollow">${attachment.type}</a>`;
            }
          }).join('')}
        </div>
        <div class="status">
          <div class="replies ${toot_active(toot, 'replies')}">
            <a href="${toot.url}" rel="nofollow"><i class="fa fa-reply fa-fw"></i>${toot_count(toot, 'replies')}</a>
          </div>
          <div class="reblogs ${toot_active(toot, 'reblogs')}">
            <a href="${toot.url}" rel="nofollow"><i class="fa fa-retweet fa-fw"></i>${toot_count(toot, 'reblogs')}</a>
          </div>
          <div class="favourites ${toot_active(toot, 'favourites')}">
            <a href="${toot.url}" rel="nofollow"><i class="fa fa-star fa-fw"></i>${toot_count(toot, 'favourites')}</a>
          </div>
        </div>
      </div>`;
    document.getElementById('mastodon-comments-list').appendChild(DOMPurify.sanitize(mastodonComment, {'RETURN_DOM_FRAGMENT': true}));

    render_toots(toots, toot.id, depth + 1)
  }

  function loadComments() {
    if (commentsLoaded) return;

    document.getElementById("mastodon-comments-list").innerHTML = "Loading comments from the Fediverse...";

    fetch('https://' + host + '/api/v1/statuses/' + id + '/context')
      .then(function(response) {
        return response.json();
      })
      .then(function(data) {
        if(data['descendants'] && Array.isArray(data['descendants']) && data['descendants'].length > 0) {
            document.getElementById('mastodon-comments-list').innerHTML = "";
            render_toots(data['descendants'], id, 0)
        } else {
          document.getElementById('mastodon-comments-list').innerHTML = "<p>Not comments found</p>";
        }

        commentsLoaded = true;
      });
  }

  function respondToVisibility(element, callback) {
    var options = {
      root: null,
    };

    var observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.intersectionRatio > 0) {
          callback();
        }
      });
    }, options);

    observer.observe(element);
  }

  var comments = document.getElementById("mastodon-comments-list");
  respondToVisibility(comments, loadComments);
</script>

and the CSS supporting the generated HTML markup:

.mastodon-comment {
  background-color: var(--block-background-color);
  border-radius: var(--block-border-radius);
  border: 1px var(--block-border-color) solid;
  padding: 20px;
  margin-bottom: 1.5rem;
  display: flex;
  flex-direction: column;
  color: var(--font-color);
  font-size: var(--font-size);
}
.mastodon-comment p {
  margin-bottom: 0px;
}
.mastodon-comment .author {
  padding-top:0;
  display:flex;
}
.mastodon-comment .author a {
  text-decoration: none;
}
.mastodon-comment .author .avatar img {
  margin-right:1rem;
  min-width:60px;
  border-radius: 5px;
}
.mastodon-comment .author .details {
  display: flex;
  flex-direction: column;
}
.mastodon-comment .author .details .name {
  font-weight: bold;
}
.mastodon-comment .author .details .user {
  color: #5d686f;
  font-size: medium;
}
.mastodon-comment .author .date {
  margin-left: auto;
  font-size: small;
}
.mastodon-comment .content {
  margin: 15px 20px;
}
.mastodon-comment .attachments {
  margin: 0px 10px;
}
.mastodon-comment .attachments > * {
  margin: 0px 10px;
}
.mastodon-comment .content p:first-child {
  margin-top:0;
  margin-bottom:0;
}
.mastodon-comment .status > div {
  display: inline-block;
  margin-right: 15px;
}
.mastodon-comment .status a {
  color: #5d686f;
  text-decoration: none;
}
.mastodon-comment .status .replies.active a {
  color: #003eaa;
}
.mastodon-comment .status .reblogs.active a {
  color: #8c8dff;
}
.mastodon-comment .status .favourites.active a {
  color: #ca8f04;
}

You’ll need also to add some config to your config.toml:

Hugo config

as well as to the meta section of your post (the toot ID to use as a thread, last attribute of the following screenshot):

Post meta

The code snippet above code, based on a toot ID that the post must define in its metadata, fetches via JSON the whole discussion thread. Once that’s done, it’s just a matter of sanitizing the HTML (to be sure nobody can inject JS into your blog but just replying to the toot) and rendering it into the HTML.

There is a small chicken and egg issue though: you need to know the ID of the toot to use as discussion thread to specify it as part of the post metadata, but probably (although not mandatory) you’d like to include the URL of the post itself, as part of the content of the toot (and ideally the link should work as soon as the toot is published).

Yes, you can edit toots, so you would be able to publish the toot, grab its ID, change the post and publish. The problem is that you’ll be losing the momentum that publishing a new toot gives you, as the toot would initially not contain the link to the blog post itself.

So my workflow, although not ideal, is going to be as follows:

  1. Publish the post without Fediverse discussion enabled (no toot ID)
  2. Publish the toot containing the URL to the post and grab its ID
  3. Change the post to include the toot ID
  4. Republish the website

It’s not ideal, as you are potentially loosing comments from early visitor to your post, but given that publishing a static website is quite fast, it should not be that many people.

Fediverse

And with that, you have a fresh new comment system based on the Fediverse for your blog!

Note: I’ve kept as a fallback for existing posts (with existing comments) Disqus, so if a post does not define a toot ID, then the comments system fall backs to Disqus. Ideally I’d love to move all those posts to Mastodon threads, but I don’t think that’s possible, as you’ll need to post toots on behalf of the users that created those comments (or you can use your own account and mimick the message as if they would have post it). Maybe something to explore in the future.

Thanks again to Carl Schwan (@carlschwan@floss.social) for its contribution to the community!

2023/01/01 Update — I’ve recently changed the markup (and some functionality) to include some extra features, as the botton links to boost / reply, as well as including the count. This is the latest code:

2023/01/02 Update — Quick hack to group / nest replies properly

2023/06/20 Update — Screenshots for blog config and post meta added

2023/06/23 Update — Media attachments rendered as part of the toot:

Toot attachments

2023/06/24 Update — Sort comments by date

2023/07/19 Update - Added CSS snippet

2023/07/23 Update - Code refactored into a standalone webcomponent: mastodon-comments