Skip to content

Instantly share code, notes, and snippets.

@nanos
Last active January 7, 2024 14:45
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nanos/e216395b4110189e7b12cec88b7a9f34 to your computer and use it in GitHub Desktop.
Save nanos/e216395b4110189e7b12cec88b7a9f34 to your computer and use it in GitHub Desktop.
Mastodon Comments

Mastodon Comments

A simple, dependency-free script to add Mastodon-powered comments to your site. For details see Adding comments to your blog, powered by mastodon

Turn this HTML

<div id="comments"></div>
<script>
  addEventListener('DOMContentLoaded', (event) => window.loadComments('{mastodon}',document.getElementById('comments')));
</script>

into this:

Mastodon Comments

A few things to consider:

  • 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.
// basic HTML escape
const escapeHtml = (unsafe) => {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Replace Emoji Short codes with their pictorial representation
const replaceEmoji = (string, emojis) => {
emojis.forEach(emoji => {
string = string.replaceAll(`:${emoji.shortcode}:`, `<img src="${escapeHtml(emoji.static_url)}" width="20" height="20">`)
});
return string;
}
loadComments = (mastodonPostUrl, container) => {
// return if not valid url - this is because omg.lol doesn't allow conditionals in the calling HTML
if(mastodonPostUrl === ''|| mastodonPostUrl === '{' + 'mastodon}') { // need to split the mastodon placeholder, to ensure that it isn't been replaced with the txt.
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 => {
if (data.descendants) {
container.innerHTML = `
<h2>Comments</h2>
<p><button class="addComment">Add a Comment</button></p>
<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>
${data.descendants.length > 1 ? `<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. 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>
`;
const dialog = document.getElementById('comment-dialog');
// open dialog on button click
Array.from(document.getElementsByClassName('addComment')).forEach(button => button.addEventListener('click', () => {
dialog.showModal();
// this is a very very crude way of not focusing the field on a mobile device.
// the reason we don't want to do this, is because that will push the modal out of view
if(dialog.getBoundingClientRect().y > 100) {
document.getElementById('instanceName').focus();
}
}));
// when click on 'Go' button: go to the instance specified by the user
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');
});
// also when pressing enter in the input field
document.getElementById('instanceName').addEventListener('keydown', e => {
if (e.key === 'Enter') {
document.getElementById('go').dispatchEvent(new Event('click'));
}
});
// copy tye post's url when pressing copy
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, if they don't trust you
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);
});
// 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();
}
})
}
return '';
})
}
:root {
--foreground: #1e2030;
--background: #f8f9fa;
--link: #0b7285;
--accent: #868e96;
}
button {
background: none;
color: inherit;
border: none;
cursor: pointer;
outline: inherit;
background-color: var(--accent);
padding: 0.25em 1em;
border-radius: 0.5em;
color: var(--background);
font-size: 16px;
}
input {
padding: 0.25em 1em;
border-radius: 0.5em;
border-width: 1px;
border-color: var(--accent);
font-size: 16px;
}
dialog {
width: 30em;
padding: 1em;
color: var(--foreground);
background: var(--background);
}
dialog::backdrop {
background-color: rgba(0,0,0,0.5);
}
dialog #close {
position: absolute;
top: 0;
right: 0;
background: none;
color: inherit;
border: none;
padding: 0.5em;
font: inherit;
outline: inherit;
}
.input-row {
display: flex;
}
.input-row > * {
display: block;
}
.input-row > input {
flex-grow: 1;
margin-right: 0.5em;
}
.input-row > button {
flex-basis: 3em;
}
.addComment {
display: block;
width: 100%;
font-size: 100%;
text-align: center;
}
.comment-list .comment {
display: flex;
padding: 0.5em;
border-width: 1px;
border-style: solid;
border-color: var(--accent);
border-radius: 0.5em;
margin-bottom: 1em;
}
.comment-list .comment .avatar {
flex-grow: 0;
flex-shrink: 0;
width: 70px;
}
.comment-list .comment .content {
flex-grow: 1;
}
.comment-list .comment .author {
width: 100%;
display: flex;
}
.comment-list .comment .author > * {
flex-grow: 1;
}
.comment-list .comment .author .date {
margin-left: auto;
text-align: right;
}
@media screen and (max-width: 850px) {
.comment-list .comment .author {
display: block;
}
.comment-list .comment .author > * {
display: block;
}
.comment-list .comment .author .date {
font-size: 0.8em;
text-align: left;
margin-left: 0;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment