blog.thms.uk

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:

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, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
}

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:

Mastodon comment

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">&times;</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:

  1. 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.
  2. 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">&times;</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:

Screenshot of the finished comments section

Epilogue

I would go amiss not mentioning a few drawbacks to this approach:

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.