Static Mastodon toots in Astro

Got an Astro site and want to embed completely static versions of posts from the Fediverse? Check out this component.

2022-08-29

Latest commit: f9895179, 2023-01-27
213 words • Reading time: 1 minute

Update from the future: Due to issues I subsequently encountered during development, this Astro code doesn’t include certain functionality that I would later add to the Hugo code on which, as explained below, this is based.

While experimenting with various static site generators (SSGs), I’ve created an Astro component for embedding static “toots” from Mastodon, similar to code I’ve offered here for Hugo and Eleventy. I offer the SToot.astro component here in somewhat edited form, with its original version available in a repo. As was the case with the earlier incarnations, you’ll specify the toot’s Mastodon instance and numeric ID:

<SToot
	instance="mastodon.social"
	id="108335994944738270"
/>

. . . to get:

Language selection is now available in the Mastodon web app. Make sure your posts are seen by people who understand them!

Supports quickly finding the right language with fuzzy search and remembers your most frequently selected languages.

Note that SToot.astro assumes you have the date-fns and md5 packages installed in the project. (By the way: in my original version of the component, I used this styling.)

Suggestion: You may want to read my earlier post about the Hugo static-toots-embedding code, particularly regarding certain limitations which result from how Mastodon itself works.

---
import { format } from "date-fns";
import md5 from "md5";

export interface Props {
	instance: string;
	id: string;
}

const { instance, id } = Astro.props as Props;

let tootLink, handleInst, mediaMD5, urlToGet, mediaStuff, videoStuff, gifvStuff, pollStuff = '';
let imageCount, votesCount = 0;

urlToGet = `https://` + instance + `/api/v1/statuses/` + id;

async function GetToot(tootURL) {
	const response = await fetch(tootURL, {
		method: "get"
	});
	return response.json()
}

let Json = await GetToot(urlToGet);

if (Json.account) {
	tootLink = `https://` + instance + `@` + Json.account.acct + `/status/` + id;
	handleInst = `@` + Json.account.acct + `@` + instance;
}

if (Json.media_attachments.length !== 0) {
	mediaMD5 = md5(Json.media_attachments[0].url)
	Json.media_attachments.forEach((type) => {
		if (Json.media_attachments[0].type == "image") {
			imageCount = ++imageCount;
		}
	})
	Json.media_attachments.forEach((type, meta) => {
		if (Json.media_attachments[0].type == "image") {
			mediaStuff = ``;
			mediaStuff = mediaStuff + `<div class="tweet-img-grid-${imageCount}"><style>.img-${mediaMD5} {aspect-ratio: ${Json.media_attachments[0].meta.original.width} / ${Json.media_attachments[0].meta.original.height}}</style>`;
			mediaStuff = mediaStuff + `<img src="${Json.media_attachments[0].url}" alt="Image ${Json.media_attachments[0].id} from toot ${id} on ${instance}" class="tweet-media-img img-${mediaMD5}`;
			if (Json.sensitive) {
				mediaStuff = mediaStuff + ` tweet-sens-blur`;
			}
			mediaStuff = mediaStuff + `" loading="lazy"`;
			if (Json.sensitive) {
				mediaStuff = mediaStuff + ` onclick="this.classList.toggle('tweet-sens-blur-no')"`;
			}
			mediaStuff = mediaStuff + `/>`;
			if (Json.sensitive) {
				mediaStuff = mediaStuff + `<div class="blur-text">Sensitive content<br />(flagged&nbsp;at&nbsp;origin)</div>`;
			}
			mediaStuff = mediaStuff + `</div>`;
		}
		if (Json.media_attachments[0].type == "video") {
			videoStuff = ``;
			videoStuff = videoStuff + `<style>.img-${mediaMD5} {aspect-ratio: ${Json.media_attachments[0].meta.original.width} / ${Json.media_attachments[0].meta.original.height}}</style>`;
			videoStuff = videoStuff + `<div class="ctr tweet-video-wrapper"><video muted playsinline controls class="ctr tweet-media-img img-${mediaMD5}`;
			if (Json.sensitive) {
				videoStuff = videoStuff + ` tweet-sens-blur`;
			}
			videoStuff = videoStuff + `"`;
			if (Json.sensitive) {
				videoStuff = videoStuff + ` onclick="this.classList.toggle('tweet-sens-blur-no')"`;
			}
			videoStuff = videoStuff + `><source src="${Json.media_attachments[0].url}"><p class="legal ctr">(Your browser doesn&rsquo;t support the <code>video</code> tag.)</p></video>`;
			if (Json.sensitive) {
				videoStuff = videoStuff + `<div class="blur-text">Sensitive content<br />(flagged&nbsp;at&nbsp;origin)</div>`;
			}
			videoStuff = videoStuff + `</div>`
		}
		if (Json.media_attachments[0].type == "gifv") {
			gifvStuff = ``;
			gifvStuff = gifvStuff + `<style>.img-${mediaMD5} {aspect-ratio: ${Json.media_attachments[0].meta.original.width} / ${Json.media_attachments[0].meta.original.height}}</style>`;
			gifvStuff = gifvStuff + `<div class="ctr tweet-video-wrapper"><video loop autoplay muted playsinline controls controlslist="nofullscreen" class="ctr tweet-media-img img-${mediaMD5}`;
			if (Json.sensitive) {
				gifvStuff = gifvStuff + ` tweet-sens-blur`;
			}
			gifvStuff = gifvStuff + `"`;
			if (Json.sensitive) {
				gifvStuff = gifvStuff + ` onclick="this.classList.toggle('tweet-sens-blur-no')"`;
			}
			gifvStuff = gifvStuff + `><source src="${Json.media_attachments[0].url}"><p class="legal ctr">(Your browser doesn&rsquo;t support the <code>video</code> tag.)</p></video>`;
			if (Json.sensitive) {
				gifvStuff = gifvStuff + `<div class="blur-text">Sensitive content<br />(flagged&nbsp;at&nbsp;origin)</div>`;
			}
			gifvStuff = gifvStuff + `</div>`
		}
	})

if (Json.poll !== null) {
	votesCount = Json.poll.votes_count;
	let pollIterator = 0;
	pollStuff = ``;
	pollStuff = pollStuff + `<div class="tweet-poll-wrapper">`;
	Json.poll.options.forEach(( options ) => {
		pollStuff = pollStuff + `<div class="tweet-poll-count"><strong>${((Json.poll.options[pollIterator].votes_count)/(votesCount)).toLocaleString("en", {style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1})}</strong></div><div class="tweet-poll-meter"><meter id="vote-count" max="${votesCount}" value=${Json.poll.options[pollIterator].votes_count}></meter></div><div class="tweet-poll-title">${Json.poll.options[pollIterator].title}</div>`;
		pollIterator = ++pollIterator;
	})
	pollStuff = pollStuff + `</div><p class="legal tweet-poll-total">${votesCount} votes</p>`;
}

---

{Json.content &&
	<blockquote class="tweet-card" cite={tootLink}>
		<div class="tweet-header">
			<a class="tweet-profile twitterExt" href=`https://${instance}/@${Json.account.acct}` rel="noopener">
				<img
					src={Json.account.avatar}
					alt=`Mastodon avatar for ${handleInst}`
					loading="lazy"
				/>
			</a>
			<div class="tweet-author">
				<a class="tweet-author-name twitterExt" href=`https://${instance}/@${Json.account.acct}` rel="noopener">{Json.account.display_name}</a>
				<a class="tweet-author-handle twitterExt" href=`https://${instance}/@${Json.account.acct}` rel="noopener">{handleInst}</a>
			</div>
		</div>
		<p class="tweet-body" set:html={Json.content}></p>

		{mediaStuff !== "" &&
			<div set:html={mediaStuff}></div>
		}
		{videoStuff !== "" &&
			<div set:html={videoStuff}></div>
		}
		{gifvStuff !== "" &&
			<div set:html={gifvStuff}></div>
		}
		{pollStuff !== "" &&
			<div set:html={pollStuff}></div>
		}
		<div class="tweet-footer">
			<a href=`https://${instance}/@${Json.account.acct}/${Json.id}` class="tweet-date twitterExt" rel="noopener">{format(new Date(Json.created_at), "MMMM d, yyyy • h:mm aa")}</a>&nbsp;<span class="pokey">(UTC)</span>
		</div>
	</blockquote>
}

Next: Back to Fastmail

Previous: Using Cloudinary with Astro and Eleventy