Embedding Mastodon posts with a web native component Sunday September 3, 2023
This is the source code for the embed widget that (hopefully) appears at the top of this post. Here is how it's being used in this page:
<embed-post
data-src="https://butts.team/@zenhob/110987984593500666"
data-maxheight="325"
data-maxwidth="800"
></embed-post>
<script async="async" src="/static/js/embed-post.js"></script>
As you can see, there isn't much to it. After my last post, where I learned about native web components, I started to wonder how hard it would be to use this technique to build a widget for embedding Mastodon posts. This is something I've never done before, but it wasn't that hard to figure out, so I wrote about the process. There are helpful links throughout, mostly to MDN, because that's my favorite reference. Since I already went into technical detail about web components in the last post, I will skip over many of those details here.
Figuring it out
First off, let's try adding .json
to the end of the post URL and
loading that in the browser. Sure enough, that displays a JSON representation
of the post! Could it be that simple? Sadly, attempting to fetch that URL from
within this page using fetch()
fails with a CORS error. That makes
sense: CORS is a safeguard to prevent web apps from making unauthorized
requests on behalf of the user, and this interface isn't meant to be available
to an outside web application. This seems like a dead end.
After an unsuccessful web search, I ended up poking around in the Mastodon
code. Here we can find the code describing CORS behavior for
Mastodon, and noticed that the /api
path is excepted from
CORS, indicating that it is for external use! It turns out there is an endpoint
under this path that Mastodon provides explicitly for this purpose:
/api/oembed
. This is a pretty roundabout way to figure that out,
but it didn't take long.
With this knowledge, we can fetch something related to the post, given the URL:
const hostname = new URL(postUrl).host
const resp = await fetch(`https://${hostname}/api/oembed?url=${postUrl}`)
const embed = await resp.json()
What we get is a response specifically for embedding, which
contains a snippet of HTML. This snippet declares an <iframe>
pointing at the
original post, and loads some JavaScript. It looks something like this:
<iframe
src="https://butts.team/@zenhob/110987984593500666/embed"
class="mastodon-embed"
allowfullscreen="allowfullscreen"
></iframe>
<script src="https://butts.team/embed.js" async="async"></script>
It's rarely a great idea to accept raw HTML and insert into your UI, there are
serious security implications! For a service under my control, with my own
post, this seems like something that can be done safely. Given that, we are
just going to shovel it into the innerHTML
of the component.
this.#content.innerHTML = embed.html
That works! At this point the post is visible through an awkwardly sized
viewport on the page. The docs for /api/oembed
explain how to
specify the size of the embedded frame, so as long as our styles match those
sizes, our embed should fit perfectly. The optional attributes data-maxwidth
and data-maxheight
are used to configure the styles and the API call.
Wrapping it up
By this point the bulk of core functionality exists, but there is still some
polishing to do. User-provided maxheight
and maxwidth
values are applied
using CSS variables in inline styles. I added a little "loading"
message that's visible until the fetch completes and the embed is visible.
The code for this component makes use of some JavaScript features that the last one did not, like static and private members. This is mostly because I haven't really written much JavaScript lately and I am unused to our age of evergreen browsers, where I can use a more modern JS dialect without issue.