As part of moving from Twitter to Mastodon I decided to add comments to the blog using Fediverse posts. Fortunately, Carl Schwan showed how he does it on his blog here.

Here are the exact tweaks to his post I did to get it working with the papermod theme I’m using on this blog.

Background

I’ve been meaning to add comments to this blog for a while, and migrating to mastodon from twitter made me think that there had to way to use mastodon toots as comments. I don’t have a lot of javascript experience since I only work with back-end services and don’t do any front-end work so I hadn’t gotten around to it, then I saw Carl’s post. Veronica Berglyd Olsen extended Carl’s post to make the comments display threaded on her blog.

Carl did all the heavy lifting and Veronica extended his work. I did have to do some minor tweaks both to merge Veronica’s update and because I’m running mainline Hugo instead of a variant build with SCSS support, make everything work with raw CSS instead of SCSS .

Installation

Carl is kind enough to publish his blog’s source in a public repository on GitLab.

Any broken bits (like not realizing there was more to getting this to css than stripping the var statements out of it in the original version of this post) here are errors made by me when I modified Carl & Veronica’s code - look at their blog posts if you run into any issues.

Instead of directly modifying the theme files in place, we’re going to override them. This will make it easier to update the theme without breaking all your comments.

First, make the override directories you’re going to need with mkdir -p layouts/default layouts/partials/mastodon static/css static/assets/js at the root level of your blog repository.

Now that the directories are in place, we’re going to make a partial with Carl’s comment code (as modified by Veronica) in it. Put this combined code in layouts/partials/mastodon/mastodon.html.

{{ with .Params.comments }}
<div class="article-content">
  <h2>Comments</h2>
  <p>You can use your Mastodon account to reply to this<a class="button" href="https://{{ .host }}/@{{ .username }}/{{ .id }}">post</a>.</p>
  <p><button class="button" id="replyButton" href="https://{{ .host }}/@{{ .username }}/{{ .id }}">Reply</button></p>
  <dialog id="toot-reply" class="mastodon" data-component="dialog">
    <h3>Reply to {{ .username }}'s post</h3>
    <p>
      With an account on the Fediverse or Mastodon, you can respond to this post.
      Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one.
    </p>
    <p>Copy and paste this URL into the search field of your favourite Fediverse app or the web interface of your Mastodon server.</p>
    <div class="copypaste">
      <input type="text" readonly="" value="https://{{ .host }}/@{{ .username }}/{{ .id }}">
      <button class="button" id="copyButton">Copy</button>
      <button class="button" id="cancelButton">Close</button>
    </div>
  </dialog>
  <p id="mastodon-comments-list"><button id="load-comment" class="button">Load comments</button></p>
  <noscript><p>You need JavaScript to view the comments.</p></noscript>
  <script src="/assets/js/purify.min.js"></script>
  <script type="text/javascript">
    const dialog = document.querySelector('dialog');

    document.getElementById('replyButton').addEventListener('click', () => {
      dialog.showModal();
    });

    document.getElementById('copyButton').addEventListener('click', () => {
      navigator.clipboard.writeText('https://{{ .host }}/@{{ .username }}/{{ .id }}');
    });

    document.getElementById('cancelButton').addEventListener('click', () => {
      dialog.close();
    });

    dialog.addEventListener('keydown', e => {
      if (e.key === 'Escape') dialog.close();
    });

    const dateOptions = {
      year: "numeric",
      month: "numeric",
      day: "numeric",
      hour: "numeric",
      minute: "numeric",
    };

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

    document.getElementById("load-comment").addEventListener("click", function() {
      document.getElementById("load-comment").innerHTML = "Loading";
      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 = "";
              data['descendants'].forEach(function(reply) {
                reply.account.display_name = escapeHtml(reply.account.display_name);
                reply.account.reply_class = reply.in_reply_to_id == "{{ .id }}" ? "reply-original" : "reply-child";
                reply.account.emojis.forEach(emoji => {
                  reply.account.display_name = reply.account.display_name.replace(`:${emoji.shortcode}:`,
                    `<img src="${escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode}" height="20" width="20" />`);
                });
                mastodonComment =
                  `<div class="mastodon-wrapper">
                    <div class="comment-level ${reply.account.reply_class}"><svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="none" transform="rotate(180)"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path stroke="#535358" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.608 12.526l7.04-6.454C13.931 4.896 16 5.806 16 7.546V11c13 0 11 16 11 16s-4-10-11-10v3.453c0 1.74-2.069 2.65-3.351 1.475l-7.04-6.454a2 2 0 010-2.948z"></path> </g></svg></div>
                    <div class="mastodon-comment">
                     <div class="avatar">
                       <img src="${escapeHtml(reply.account.avatar_static)}" height=60 width=60 alt="">
                     </div>
                     <div class="content">
                       <div class="author">
                         <a href="${reply.account.url}" rel="nofollow">
                           <span>${reply.account.display_name}</span>
                           <span class="disabled">${escapeHtml(reply.account.acct)}</span>
                         </a>
                         <a class="date" href="${reply.uri}" rel="nofollow">
                           ${reply.created_at.substr(0, 10)}
                         </a>
                       </div>
                       <div class="mastodon-comment-content">${reply.content}</div>
                     </div>
                   </div>
                  </div>`;
                document.getElementById('mastodon-comments-list').appendChild(DOMPurify.sanitize(mastodonComment, {'RETURN_DOM_FRAGMENT': true}));
              });
          } else {
            document.getElementById('mastodon-comments-list').innerHTML = "<p>No comments found</p>";
          }
        });
      });
  </script>
</div>
{{ end }}

If you want to change the threading icon, find another svg and replace the <svg>....</svg> tag in the comment-level div.

The code needs a css file to define how the comments are going to look. I defined the background-color, padding, and border-radius inline and compiled Carl’s original scss to css with scss-to-css instead of using SCSS variables. I’m running stock hugo and didn’t want to switch to the variant supporting SCSS since I’m not using it anywhere else.

Carl’s original SCSS code is here.

Here’s a compiled version (with Veronica’s additions merged in) below in static/css/mastodon.css.

.mastodon-wrapper {
  display: flex;
  gap: 3rem;
  flex-direction: row;
}

.comment-level {
  max-width: 3rem;
  min-width: 3rem;
}

.reply-original {
  display: none;
}

#article-comments div.reply-original {
  display: none;
}

#article-comments div.reply-child {
  display: block;
  flex: 0 0 1.75rem;
  text-align: right;
}

.mastodon-comment {
  background-color: #e9e5e5;
  border-radius: 10px;
  padding: 30px;
  margin-bottom: 1rem;
  display: flex;
  gap: 1rem;
  flex-direction: column;
  flex-grow: 2;
}
.mastodon-comment .comment {
  display: flex;
  flex-direction: row;
  gap: 1rem;
  flex-wrap: true;
}
.mastodon-comment .comment-avatar img {
  width: 6rem;
}
.mastodon-comment .content {
  flex-grow: 2;
}
.mastodon-comment .comment-author {
  display: flex;
  flex-direction: column;
}
.mastodon-comment .comment-author-name {
  font-weight: bold;
}
.mastodon-comment .comment-author-name a {
  display: flex;
  align-items: center;
}
.mastodon-comment .comment-author-date {
  margin-left: auto;
}
.mastodon-comment .disabled {
  color: #34495e;
}
.mastodon-comment-content p:first-child {
  margin-top: 0;
}
.mastodon {
  --dlg-bg: #282c37;
  --dlg-w: 600px;
  --dlg-color: #9baec8;
  --dlg-button-p: 0.75em 2em;
  --dlg-outline-c: #00D9F5;
}
.copypaste {
  display: flex;
  align-items: center;
  gap: 10px;
}
.copypaste input {
  display: block;
  font-family: inherit;
  background: #17191f;
  border: 1px solid #8c8dff;
  color: #9baec8;
  border-radius: 4px;
  padding: 6px 9px;
  line-height: 22px;
  font-size: 14px;
  transition: border-color 0.3s linear;
  flex: 1 1 auto;
  overflow: hidden;
}
.copypaste .button {
  border: 10px;
  border-radius: 4px;
  box-sizing: border-box;
  color: #fff;
  cursor: pointer;
  display: inline-block;
  font-family: inherit;
  font-size: 15px;
  font-weight: 500;
  letter-spacing: 0;
  line-height: 22px;
  overflow: hidden;
  padding: 7px 18px;
  position: relative;
  text-align: center;
  text-decoration: none;
  text-overflow: ellipsis;
  white-space: nowrap;
  width: auto;
  background-color: #232730;
}
.copypaste .button:hover {
  background-color: #16181e;
}

Update: Stewart Wright posted an article based on this, but his css works in dark mode too - add this to mastodon.css to enable dark mode.

.dark .mastodon-comment {
  background-color: #36383d;
}
.dark .mastodon-comment .disabled {
  color: #ad55fd;
}

It also needs a local copy of DOMPurify - curl https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.min.js > static/assets/js/purify.min.js

Finally, create layouts/default/single.html

First we need to add a link to the style sheet we added - I added a link to the stylesheet at the beginning <link rel="stylesheet" type="text/css" href="{{.Site.BaseURL}}css/mastodon.css" />

We also need to add the mastodon.html partial file to present the Load Commants and Reply buttons.

Right after the post-content div, I added

<div>
  {{ partial "mastodon/mastodon.html" .}}
</div>

Here’s the full version of my modified copy of the papermod layout file so you don’t have to edit it yourself.

{{- define "main" }}
<link rel="stylesheet" type="text/css" href="{{.Site.BaseURL}}css/mastodon.css" />
<article class="post-single">
  <header class="post-header">
    {{ partial "breadcrumbs.html" . }}
    <h1 class="post-title">
      {{ .Title }}
      {{- if .Draft }}<sup><span class="entry-isdraft">&nbsp;&nbsp;[draft]</span></sup>{{- end }}
    </h1>
    {{- if .Description }}
    <div class="post-description">
      {{ .Description }}
    </div>
    {{- end }}
    {{- if not (.Param "hideMeta") }}
    <div class="post-meta">
      {{- partial "post_meta.html" . -}}
      {{- partial "translation_list.html" . -}}
      {{- partial "edit_post.html" . -}}
      {{- partial "post_canonical.html" . -}}
    </div>
    {{- end }}
  </header>
  {{- $isHidden := .Params.cover.hidden | default site.Params.cover.hiddenInSingle | default site.Params.cover.hidden }}
  {{- partial "cover.html" (dict "cxt" . "IsHome" false "isHidden" $isHidden) }}
  {{- if (.Param "ShowToc") }}
  {{- partial "toc.html" . }}
  {{- end }}

  {{- if .Content }}
  <div class="post-content">
    {{- if not (.Param "disableAnchoredHeadings") }}
    {{- partial "anchored_headings.html" .Content -}}
    {{- else }}{{ .Content }}{{ end }}
  </div>
  <div>
    {{ partial "mastodon/mastodon.html" .}}
  </div>
  {{- end }}

  <footer class="post-footer">
    {{- $tags := .Language.Params.Taxonomies.tag | default "tags" }}
    <ul class="post-tags">
      {{- range ($.GetTerms $tags) }}
      <li><a href="{{ .Permalink }}">{{ .LinkTitle }}</a></li>
      {{- end }}
    </ul>
    {{- if (.Param "ShowPostNavLinks") }}
    {{- partial "post_nav_links.html" . }}
    {{- end }}
    {{- if (and site.Params.ShowShareButtons (ne .Params.disableShare true)) }}
    {{- partial "share_icons.html" . -}}
    {{- end }}
  </footer>

  {{- if (.Param "comments") }}
  {{- partial "comments.html" . }}
  {{- end }}
</article>

{{- end }}{{/* end main */}}

Usage

Now that all the files are in place, all you have to do to add comments to a post is add a comments stanza to the front matter in your post file to let the comment load know what toot to look at.

Here’s an example of the comments stanza for this post:

comments:
  host: hachyderm.io
  username: unixorn
  id: 110149495764332469

The only awkward bit is that first you need to create a toot pointing at your blog post, then get the id, then update the post front matter to include the comment section.

Updates

  • 2023-03-06 - Compiled the SCSS to CSS. Thanks for the catch, Carl.
  • 2023-03-06 - Added indented comments based on Veronica Berglyd Olsen’s post
  • 2023-04-24 - Fix typo in file paths - /partial/ should have been /partials/ - thanks for the catch, Stewart. Also updated mastodon.css to include Stewart’s dark css section to make the comments visible when the blog is in dark mode.