Adding comments to your blog, powered by mastodon
Usually, when you want to add comments to a blog, you have a few options, which range from 3rd party dedicated comment solutions, such as Disqus, to implementing some server side comments. I'm not a big fan of 3rd party comment solutions, because of the privacy implications. My blog is running through a static site generator, so server side comments aren't an option either. So, I figured I'd use Mastodon to add comments to my blog.
This post runs through how I set up a very simple, dependency-free solution to add Mastodon-powered comments to my blog. If you want to skip the details, you can find the finished code in this GitHub Gist. But please make sure you'll read the Epilogue at the bottom though, to find out about some important considerations before using this.
Prerequisites
OK, so there are quite a few things you need before you can get started:
First and foremost you must have a Mastodon account. Presumably you can do similar stuff if you have an account with a different Fediverse service, but the code I present here uses the Mastodon API.
Secondly, anyone commenting on your post must have a Fediverse account. (This doesn't need to be Mastodon.)
Thirdly, you'll need to post a thread on Mastodon for every post you want to enable comments on, and supply the link to that thread with each blog post. Any replies to your thread will show as comments on your blog post.
My blog is powered by omg.lol's weblog service, where posts are authored in Markdown, with Front Matter support. I'll simply a line like the below to the top of my blog post, giving me access to the comment link using the {mastodon}
parameter:
mastodon: https://mstdn.thms.uk/@michael/109858517006175000
Fetching your comments
With that out of the way, let's get started by setting up some really simple HTML:
<div id="comments"></div>
<script>
addEventListener('DOMContentLoaded', (event) =>
window.loadComments('{mastodon}', document.getElementById('comments'))
);
</script>
Our html container is completely empty: All content will be added by our script. This is because we want to ensure we aren't getting any undesired scaffolding / empty HTML elements on the page, for posts where we haven't supplied a Mastodon URL (yet).
So, we'll need to create a loadComments(mastodonPostUrl, container)
function that will take two parameters:
- The
container
parameter will be the DOM node that will hold the comments. - The
mastodonPostUrl
parameter will either be the string{mastodon}
(if we haven't supplied the mastodon parameter), or a mastodon post URL that looks like this:https://mstdn.thms.uk/@michael/109858517006175000
. In the former case we want to bail out, but in the latter case we want to fetch the replies to the specified post.
In order to get these replies, we are using the Mastodon API's context
endpoint. The URL for that endpoint looks like this: /api/v1/statuses/:id/context
, so let's start creating our loadComments
method:
loadComments = (mastodonPostUrl, container) => {
// return if not valid url
if(mastodonPostUrl === ''|| mastodonPostUrl === '{' + 'mastodon}') {
return false;
}
// convert the supplied mastodon post url to the relevant endpoint URL, by replacing
// `@username` with `api/v1/statuses` and appending `/context`
const mastodonApiUrl = mastodonPostUrl.replace(/@[^\/]+/, 'api/v1/statuses') + '/context';
// fetch replies and get JSON
fetch(mastodonApiUrl)
.then(response => {
return response.json();
})
.then(data => {
// Do something
});
}
Let's have a look at the data
we get back from the API: The context
endpoint returns JSON with a descendants
key that's an array of Status
objects. The Documentation has a full description of all the attributes of those Status
objects (it's a lot), but below is an excerpt with just the ones that I'll use here. By all means have a closer look at the documentation yourself, if you want to pull out additional data (such as attached images, or link previews), but here is what I'm working with right now:
{
"created_at": "2023-02-13T19:50:54.000Z",
"account": {
"url": "https://phpc.social/@outofcontrol",
"avatar_static": "https://mstdn-files.thms.uk/cache/accounts/avatars/109/328/206/639/080/036/original/f22d26e22cb3b298.jpg",
"display_name": "Out of Control :laravel: 🇨🇦",
"emojis": [
{
"shortcode": "laravel",
"url": "https://mstdn-files.thms.uk/cache/custom_emojis/images/000/000/145/original/34fa47451e45db3d.png",
"static_url": "https://mstdn-files.thms.uk/cache/custom_emojis/images/000/000/145/static/34fa47451e45db3d.png",
"visible_in_picker": true
}
],
},
"url": "https://phpc.social/@outofcontrol/109859166938368628",
"content": "<p><span class=\"h-card\"><a href=\"https://mstdn.thms.uk/@michael\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>michael</span></a></span> congrats on your first composer package!</p>",
"emojis": [],
},
As you can see, we have the date and time, some info about the author, and the actual reply content. Interestingly, both for the status content, and for the author name, we also have an emojis
attribute, that will list any emoji contained in the text, so that we can replace them, so let's create a helper function that will do just that:
// Replace Emoji Short codes with their pictorial representation
const replaceEmoji = (string, emojis) => {
emojis.forEach(emoji => {
string = string.replaceAll(
`:${emoji.shortcode}:`,
`<img src="${emoji.static_url}" width="20" height="20">`
)
});
return string;
}
There is one more helper we need to build before we can proceed: We must never ever dump user supplied content onto our site, without sanitising it first. Otherwise we risk opening ourselves to XSS attacks. While the content
attribute for each status is being sanitised by Mastodon before it's returned by the API (so should therefore be safe to insert as is), I can find no such guarantee about the user name. So we are building a simple function to escape any and all HTML from a string, which we use for all user supplied content returned from the API were we are not sure whether Mastodon sanitises it:
// basic HTML escape
const escapeHtml = (unsafe) => {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
With all of that out of the way, it's finally time to use the API response, and add the comments to the page:
fetch(mastodonApiUrl)
.then(response => {
return response.json();
})
.then(data => {
if (data.descendants) {
container.innerHTML = `
<h2>Comments</h2>
<div class="comment-list">
${data.descendants.reduce((html, status) => {
return html + `
<div class="comment">
<div class="avatar">
<img src="${status.account.avatar_static}" height="60" width="60" alt="">
</div>
<div class="content">
<div class="author">
<a target="_blank" href="${status.account.url}" rel="nofollow">
<span>${replaceEmoji(escapeHtml(status.account.display_name), status.account.emojis)}</span>
</a>
<a target="_blank" class="date" href="${status.url}" rel="nofollow">
${new Date(status.created_at).toLocaleString()}
</a>
</div>
<div class="mastodon-comment-content">${replaceEmoji(status.content, status.emojis)}</div>
</div>
</div>
`}, '')}
</div>`;
}
});
With that, you'll get a list of comments, that - with just a little bit of CSS sprinkled in (see the Gist linked above) - will look like something like this:
Adding comments
Now we need to add functionality for your visitors to add comments. This is where it gets interesting, and potentially a bit confusing for your visitors: They won't be able to add comments right on your blog post. Instead we'll direct them to search for your post on their Fediverse instance, and post a reply there.
Because it's 2023, you should use the dialog element, so we are going to do that. Yay, because it means we need far less code to get a nice looking dialogue that performs really well.
Let's add our dialog, as well as some event handlers to both open and close it when needed:
container.innerHTML += `
<p><button class="addComment">Add a Comment</button></p>
<dialog id="comment-dialog">
<h3>Reply to this post</h3>
<button title="Cancel" id="close">×</button>
<p>
Comments are powered by Mastodon. With an account on Mastodon (or elsewhere on the Fediverse), you can respond to this post.
<p>
</dialog>
`;
const dialog = document.getElementById('comment-dialog');
// open dialog on button click
Array.from(document.getElementsByClassName('addComment')).forEach(button =>
button.addEventListener('click', () => {
dialog.showModal();
})
);
// close dialog on button click, or escape button
document.getElementById('close').addEventListener('click', () => {
dialog.close();
});
dialog.addEventListener('keydown', e => {
if (e.key === 'Escape') {
dialog.close();
}
});
// Close dialog, if clicked on backdrop
dialog.addEventListener('click', event => {
var rect = dialog.getBoundingClientRect();
var isInDialog =
rect.top <= event.clientY
&& event.clientY <= rect.top + rect.height
&& rect.left <= event.clientX
&& event.clientX <= rect.left + rect.width;
if (!isInDialog) {
dialog.close();
}
})
Now, what's the best way to direct would-be commenters to the correct place? I think we'll add two modes of operation here, to make it as easy as possible:
- For users of Mastodon we can make it fairly simple: We'll provide an input field where they can provide their instance name. Then we can take them right to the comment thread on their own server, where they can just hit reply. For convenience, we'll store the instance name they provided in their browser using local storage, so when they come back they won't need to type it again.
- For other users, we can't do that, because we don't know how to search for your a thread in their preferred software. So we'll provide the URL to your post and tell them to copy and paste it into their instance search manually.
Here is how this looks like:
`<dialog id="comment-dialog">
<h3>Reply to this post</h3>
<button title="Cancel" id="close">×</button>
<p>
Comments are powered by Mastodon. With an account on Mastodon (or elsewhere on the Fediverse),
you can respond to this post. Simply enter your mastodon instance below, and add a reply:
<p>
<p class="input-row">
<input type="text" inputmode="url" autocapitalize="none" autocomplete="off"
value="${ escapeHtml(localStorage.getItem('mastodonUrl')) ?? '' }" id="instanceName"
placeholder="mastodon.social">
<button class="button" id="go">Go</button>
</p>
<p>Alternatively, copy this URL and paste it into the search bar of your Mastodon app:</p>
<p class="input-row">
<input type="text" readonly id="copyInput" value="${ mastodonPostUrl }">
<button class="button" id="copy">Copy</button>
</p>
</dialog>`
We've added the inputmode="url" autocapitalize="none" autocomplete="off"
attributes to our instance input field, to ensure that users of mobile devices get the best possible experience here.
Now, let's add the desired behaviour: When clicking the 'Go' button, or when pressing enter in the input field, we'll open our post in the visitor's instance. We use Mastodon's authorize_interaction
endpoint for this purpose:
document.getElementById('go').addEventListener('click', () => {
let url = document.getElementById('instanceName').value.trim();
if (url === '') {
// bail out - window.alert is not very elegant, but it works
window.alert('Please provide the name of your instance');
return;
}
// store the url in the local storage for next time
localStorage.setItem('mastodonUrl', url);
if (!url.startsWith('https://')) {
url = `https://${url}`;
}
window.open(`${url}/authorize_interaction?uri=${mastodonPostUrl}`, '_blank');
});
document.getElementById('instanceName').addEventListener('keydown', e => {
if (e.key === 'Enter') {
document.getElementById('go').dispatchEvent(new Event('click'));
}
});
Now, the final piece of functionality: For users who can't - or don't want to - find your thread this way, we'll want to insert the link to our thread into the clipboard when clicking 'Copy'. Additionally we want to provide some visual feedback:
document.getElementById('copy').addEventListener('click', () => {
// select the input field, both for visual feedback, and so that the
// user can use CTRL/CMD+C for manual copying
document.getElementById('copyInput').select();
navigator.clipboard.writeText(mastodonPostUrl);
// Confirm this by changing the button text
document.getElementById('copy').innerHTML = 'Copied!';
// restore button text after a second.
window.setTimeout(() => {
document.getElementById('copy').innerHTML = 'Copy';
}, 1000);
});
And that's it. you now have Mastodon-powered comments on your blog post: Here is what the finished product looks like for me:
Epilogue
I would go amiss not mentioning a few drawbacks to this approach:
- If you ever need to migrate your mastodon account, you'll typically loose all your old posts, including your old blog comments.
- Unless you are a moderator on your mastodon instance, you have no moderation ability on your comments. That could be a big deal.
- If your blog visitors have accounts on instances that have de-federated from your instance (or the other way around), they will not be able to comment.
- In short, unless you are using your own Mastodon instance, you'll need to put a lot of trust into your instance's admins.
- As much as I'd like to think that my blog posts are so engaging people will sign up to Mastodon in droves just to be able to comment on my posts, that's obviously not happening, so essentially you'll only get comments from existing Fediverse users.
- Mastodon has a rate limit on that context API endpoint, so if you get loads of visitors to your blog, you may need to cache responses somewhere.
I, however, run my own instance, which I have no intention to move away from, and most of these points are therefore acceptable to me. Additionally, the Fediverse is the only place where I post about my blog, so it's extremely likely that every single one of the 5 readers of my blog will already have an account, so that point also doesn't concern me.
But all of these points are definitely worth thinking carefully about.
Let me know what you think in the comments.