diff --git a/static/js/webmention.js b/static/js/webmention.js
new file mode 100644
index 0000000..49d23d3
--- /dev/null
+++ b/static/js/webmention.js
@@ -0,0 +1,475 @@
+/* webmention.js
+
+Simple thing for embedding webmentions from webmention.io into a page, client-side.
+
+(c)2018-2022 fluffy (http://beesbuzz.biz)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+GitHub repo (for latest released versions, issue tracking, etc.):
+
+ https://github.com/PlaidWeb/webmention.js
+
+Basic usage:
+
+
+
+
+Allowed parameters:
+
+ page-url:
+
+ The base URL to use for this page. Defaults to window.location
+
+ add-urls:
+
+ Additional URLs to check, separated by |s
+
+ id:
+
+ The HTML ID for the object to fill in with the webmention data.
+ Defaults to "webmentions"
+
+ wordcount:
+
+ The maximum number of words to render in reply mentions.
+
+ max-webmentions:
+
+ The maximum number of mentions to retrieve. Defaults to 30.
+
+ prevent-spoofing:
+
+ By default, Webmentions render using the mf2 'url' element, which plays
+ nicely with webmention bridges (such as brid.gy and telegraph)
+ but allows certain spoofing attacks. If you would like to prevent
+ spoofing, set this to a non-empty string (e.g. "true").
+
+ sort-by:
+
+ What to order the responses by; defaults to 'published'. See
+ https://github.com/aaronpk/webmention.io#api
+
+ sort-dir:
+
+ The order to sort the responses by; defaults to 'up' (i.e. oldest
+ first). See https://github.com/aaronpk/webmention.io#api
+
+ comments-are-reactions:
+
+ If set to a non-empty string (e.g. "true"), will display comment-type responses
+ (replies/mentions/etc.) as being part of the reactions
+ (favorites/bookmarks/etc.) instead of in a separate comment list.
+
+A more detailed example:
+
+
+
+
+
+
+
+*/
+
+// Begin LibreJS code licensing
+// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt
+
+(function () {
+ "use strict";
+
+ // Shim i18next
+ window.i18next = window.i18next || {
+ t: function t(/** @type {string} */key) { return key; }
+ }
+ const t = window.i18next.t.bind(window.i18next);
+
+ /**
+ * Read the configuration value.
+ *
+ * @param {string} key The configuration key.
+ * @param {string} dfl The default value.
+ * @returns {string}
+ */
+ function getCfg(key, dfl) {
+ return document.currentScript.getAttribute("data-" + key) || dfl;
+ }
+
+ const refurl = getCfg("page-url", window.location.href.replace(/#.*$/, ""));
+ const addurls = getCfg("add-urls", undefined);
+ const containerID = getCfg("id", "webmentions");
+ /** @type {Number} */
+ const textMaxWords = getCfg("wordcount");
+ const maxWebmentions = getCfg("max-webmentions", 30);
+ const mentionSource = getCfg("prevent-spoofing") ? "wm-source" : "url";
+ const sortBy = getCfg("sort-by", "published");
+ const sortDir = getCfg("sort-dir", "up");
+ /** @type {boolean} */
+ const commentsAreReactions = getCfg("comments-are-reactions", false);
+
+ /**
+ * @typedef MentionType
+ * @type {"in-reply-to"|"like-of"|"repost-of"|"bookmark-of"|"mention-of"|"rsvp"|"follow-of"}
+ */
+
+ /**
+ * Maps a reaction to a hover title.
+ *
+ * @type {Record}
+ */
+ const reactTitle = {
+ "in-reply-to": t("replied"),
+ "like-of": t("liked"),
+ "repost-of": t("reposted"),
+ "bookmark-of": t("bookmarked"),
+ "mention-of": t("mentioned"),
+ "rsvp": t("RSVPed"),
+ "follow-of": t("followed")
+ };
+
+ /**
+ * Maps a reaction to an emoji.
+ *
+ * @type {Record}
+ */
+ const reactEmoji = {
+ "in-reply-to": "💬",
+ "like-of": "❤️",
+ "repost-of": "🔄",
+ "bookmark-of": "⭐️",
+ "mention-of": "💬",
+ "rsvp": "📅",
+ "follow-of": "🐜"
+ };
+
+ /**
+ * @typedef RSVPEmoji
+ * @type {"yes"|"no"|"interested"|"maybe"|null}
+ */
+
+ /**
+ * Maps a RSVP to an emoji.
+ *
+ * @type {Record}
+ */
+ const rsvpEmoji = {
+ "yes": "✅",
+ "no": "❌",
+ "interested": "💡",
+ "maybe": "💭"
+ };
+
+ /**
+ * HTML escapes the string.
+ *
+ * @param {string} text The string to be escaped.
+ * @returns {string}
+ */
+ function entities(text) {
+ return text.replace(/[&<>"]/g, (tag) => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ }[tag] || tag));
+ }
+
+ /**
+ * Creates the markup for an reaction image.
+ *
+ * @param {Reaction} r
+ * @param {boolean} isComment
+ * @returns {string}
+ */
+ function reactImage(r, isComment) {
+ const who = entities(
+ r.author?.name || r.url.split("/")[2]
+ );
+ /** @type {string} */
+ let response = reactTitle[r["wm-property"]] || t("reacted");
+ if (!isComment && r.content && r.content.text) {
+ response += ": " + extractComment(r);
+ }
+
+ let authorPhoto = '';
+ if (r.author && r.author.photo) {
+ authorPhoto = `
+
+ `;
+ } else {
+ authorPhoto = `
+
+ `;
+ }
+
+ let emoji = '';
+ if (reactEmoji[r['wm-property']]) {
+ emoji = `${reactEmoji[r['wm-property']]}`;
+ } else {
+ emoji = `💥`;
+ }
+
+ let rsvp = '';
+ if (r.rsvp && rsvpEmoji[r.rsvp]) {
+ rsvp = `${rsvpEmoji[r.rsvp]}`;
+ }
+
+ return`
+
+ ${authorPhoto}
+ ${emoji}
+ ${rsvp}
+
+ `;
+ }
+
+ /**
+ * Strip the protocol off a URL.
+ *
+ * @param {string} url The URL to strip protocol off.
+ * @returns {string}
+ */
+ function stripurl(url) {
+ return url.substr(url.indexOf('//'));
+ }
+
+ /**
+ * Deduplicate multiple mentions from the same source URL.
+ *
+ * @param {Array} mentions Mentions of the source URL.
+ * @return {Array}
+ */
+ function dedupe(mentions) {
+ /** @type {Array} */
+ const filtered = [];
+ /** @type {Record} */
+ const seen = {};
+
+ mentions.forEach(function(r) {
+ // Strip off the protocol (i.e. treat http and https the same)
+ const source = stripurl(r.url);
+ if (!seen[source]) {
+ filtered.push(r);
+ seen[source] = true;
+ }
+ });
+
+ return filtered;
+ }
+
+ /**
+ * Extract comments from a reaction.
+ *
+ * @param {Reactions} c
+ * @returns string
+ */
+ function extractComment(c) {
+ let text = entities(c.content.text);
+
+ if (textMaxWords) {
+ let words = text.replace(/\s+/g,' ').split(' ', textMaxWords + 1);
+ if (words.length > textMaxWords) {
+ words[textMaxWords - 1] += '…';
+ words = words.slice(0, textMaxWords);
+ text = words.join(' ');
+ }
+ }
+
+ return text;
+ }
+
+ /**
+ * Format comments as HTML.
+ *
+ * @param {Array} comments The comments to format.
+ * @returns string
+ */
+ function formatComments(comments) {
+ const headline = `${t('Responses')}
`;
+ const markup = comments
+ .map((c) => {
+ const image = reactImage(c, true);
+
+ let source = entities(c.url.split('/')[2]);
+ if (c.author && c.author.name) {
+ source = entities(c.author.name);
+ }
+ const link = `${source}`;
+
+ let linkclass = "name";
+ let linktext = `(${t("mention")})`;
+ if (c.name) {
+ linkclass = "name";
+ linktext = entities(c.name);
+ } else if (c.content && c.content.text) {
+ linkclass = "text";
+ linktext = extractComment(c);
+ }
+
+ const type = `${link} ${linktext}`;
+
+ return `${image} ${type}`;
+ })
+ .join('');
+ return `
+ ${headline}
+
+ `;
+ }
+
+ /**
+ * @typedef {Object} Reaction
+ * @property {string} url
+ * @property {Object?} author
+ * @property {string?} author.name
+ * @property {string?} author.photo
+ * @property {Object?} content
+ * @property {string?} content.text
+ * @property {RSVPEmoji?} rsvp
+ * @property {MentionType?} wm-property
+ * @property {string?} wm-source
+ */
+
+ /**
+ * Formats a list of reactions as HTML.
+ *
+ * @param {Array} reacts List of reactions to format
+ * @returns string
+ */
+ function formatReactions(reacts) {
+ const headline = `${t('Reactions')}
`;
+
+ const markup = reacts.map((r) => reactImage(r)).join('');
+
+ return `
+ ${headline}
+
+ `;
+ }
+
+ /**
+ * @typedef WebmentionResponse
+ * @type {Object}
+ * @property {Array} children
+ */
+
+ /**
+ * Register event listener.
+ */
+ window.addEventListener("load", async function () {
+ const container = document.getElementById(containerID);
+ if (!container) {
+ // no container, so do nothing
+ return;
+ }
+
+ const pages = [stripurl(refurl)];
+ if (!!addurls) {
+ addurls.split('|').forEach(function (url) {
+ pages.push(stripurl(url));
+ });
+ }
+
+ let apiURL = `https://webmention.io/api/mentions.jf2?per-page=${maxWebmentions}&sort-by=${sortBy}&sort-dir=${sortDir}`;
+
+ pages.forEach(function (path) {
+ apiURL += `&target[]=${encodeURIComponent('http:' + path)}&target[]=${encodeURIComponent('https:' + path)}`;
+ });
+
+ /** @type {WebmentionResponse} */
+ let json = {};
+ try {
+ const response = await window.fetch(apiURL);
+ if (response.status >= 200 && response.status < 300) {
+ json = await response.json();
+ } else {
+ console.error("Could not parse response");
+ new Error(response.statusText);
+ }
+ } catch(error) {
+ // Purposefully not escalate further, i.e. no UI update
+ console.error("Request failed", error);
+ }
+
+ /** @type {Array} */
+ let comments = [];
+ /** @type {Array} */
+ const collects = [];
+
+ if (commentsAreReactions) {
+ comments = collects;
+ }
+
+ /** @type {Record>} */
+ const mapping = {
+ "in-reply-to": comments,
+ "like-of": collects,
+ "repost-of": collects,
+ "bookmark-of": collects,
+ "follow-of": collects,
+ "mention-of": comments,
+ "rsvp": comments
+ };
+
+ json.children.forEach(function(child) {
+ // Map each mention into its respective container
+ const store = mapping[child['wm-property']];
+ if (store) {
+ store.push(child);
+ }
+ });
+
+ // format the comment-type things
+ let formattedComments = '';
+ if (comments.length > 0 && comments !== collects) {
+ formattedComments = formatComments(dedupe(comments));
+ }
+
+ // format the other reactions
+ let reactions = '';
+ if (collects.length > 0) {
+ reactions = formatReactions(dedupe(collects));
+ }
+
+ container.innerHTML = `${formattedComments}${reactions}`;
+ });
+}());
+
+// End-of-file marker for LibreJS
+// @license-end
diff --git a/static/js/webmention.min.js b/static/js/webmention.min.js
new file mode 100644
index 0000000..94565b8
--- /dev/null
+++ b/static/js/webmention.min.js
@@ -0,0 +1,3 @@
+// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt
+!function(){"use strict";window.i18next=window.i18next||{t:function(n){return n}};const n=window.i18next.t.bind(window.i18next);function t(n,t){return document.currentScript.getAttribute("data-"+n)||t}const e=t("page-url",window.location.href.replace(/#.*$/,"")),o=t("add-urls",void 0),s=t("id","webmentions"),r=t("wordcount"),i=t("max-webmentions",30),a=t("prevent-spoofing")?"wm-source":"url",l=t("sort-by","published"),c=t("sort-dir","up"),u=t("comments-are-reactions",!1),p={"in-reply-to":n("replied"),"like-of":n("liked"),"repost-of":n("reposted"),"bookmark-of":n("bookmarked"),"mention-of":n("mentioned"),rsvp:n("RSVPed"),"follow-of":n("followed")},f={"in-reply-to":"💬","like-of":"❤️","repost-of":"🔄","bookmark-of":"⭐️","mention-of":"💬",rsvp:"📅","follow-of":"🐜"},m={yes:"✅",no:"❌",interested:"💡",maybe:"💭"};function d(n){return n.replace(/[&<>"]/g,(n=>({"&":"&","<":"<",">":">",'"':"""}[n]||n)))}function w(t,e){const o=d(t.author?.name||t.url.split("/")[2]);let s=p[t["wm-property"]]||n("reacted");!e&&t.content&&t.content.text&&(s+=": "+$(t));let r="";r=t.author&&t.author.photo?`\n
\n `:`\n
\n `;let i="";i=f[t["wm-property"]]?`${f[t["wm-property"]]}`:'💥';let l="";return t.rsvp&&m[t.rsvp]&&(l=`${m[t.rsvp]}`),`\n \n ${r}\n ${i}\n ${l}\n \n `}function h(n){return n.substr(n.indexOf("//"))}function g(n){const t=[],e={};return n.forEach((function(n){const o=h(n.url);e[o]||(t.push(n),e[o]=!0)})),t}function $(n){let t=d(n.content.text);if(r){let n=t.replace(/\s+/g," ").split(" ",r+1);n.length>r&&(n[r-1]+="…",n=n.slice(0,r),t=n.join(" "))}return t}window.addEventListener("load",(async function(){const t=document.getElementById(s);if(!t)return;const r=[h(e)];o&&o.split("|").forEach((function(n){r.push(h(n))}));let p=`https://webmention.io/api/mentions.jf2?per-page=${i}&sort-by=${l}&sort-dir=${c}`;r.forEach((function(n){p+=`&target[]=${encodeURIComponent("http:"+n)}&target[]=${encodeURIComponent("https:"+n)}`}));let f={};try{const n=await window.fetch(p);n.status>=200&&n.status<300?f=await n.json():(console.error("Could not parse response"),new Error(n.statusText))}catch(n){console.error("Request failed",n)}let m=[];const x=[];u&&(m=x);const y={"in-reply-to":m,"like-of":x,"repost-of":x,"bookmark-of":x,"follow-of":x,"mention-of":m,rsvp:m};f.children.forEach((function(n){const t=y[n["wm-property"]];t&&t.push(n)}));let k="";m.length>0&&m!==x&&(k=function(t){return`\n ${n("Responses")}
\n \n `}(g(m)));let b="";var B;x.length>0&&(B=g(x),b=`\n ${n("Reactions")}
\n ${B.map((n=>w(n))).join("")}
\n `),t.innerHTML=`${k}${b}`}))}();
+// @license-end
\ No newline at end of file