diff --git a/src/server/csp.py b/src/server/csp.py
index f7cebf42606..78004b61414 100644
--- a/src/server/csp.py
+++ b/src/server/csp.py
@@ -15,6 +15,7 @@
],
'connect-src': [
'\'self\'',
+ 'webmention.io',
'discuss.httparchive.org',
'www.google-analytics.com',
'www.googletagmanager.com'
diff --git a/src/static/css/page.css b/src/static/css/page.css
index a8f8afaf731..1063ab47906 100644
--- a/src/static/css/page.css
+++ b/src/static/css/page.css
@@ -250,7 +250,8 @@
}
.authors h2,
-.chapter-links h2 {
+.chapter-links h2,
+.webmentions h2 {
padding: 16px 0;
padding: 1rem 0;
}
@@ -258,7 +259,9 @@
.authors,
.authors h2,
.chapter-links,
-.chapter-links h2 {
+.chapter-links h2,
+.webmentions,
+.webmentions h2 {
border-bottom: 1px solid #1a2b490a;
font-size: 17px;
font-size: 1.0625rem;
@@ -858,6 +861,87 @@ pre {
width: 100%;
}
+/* Webmention code */
+.reaction-tabs [role="tab"][aria-selected="true"] {
+ z-index: 3;
+ font-weight: bold;
+ border-bottom: 0.5rem solid #a8caba;
+}
+
+.reaction-tabs [role="tab"] {
+ position: relative;
+ z-index: 1;
+ background: white;
+ border: 0;
+ padding: 0.6rem 0.75rem 0.6rem;
+}
+
+.reactions [role="tabpanel"] {
+ margin-top: 1.5rem;
+}
+
+.reaction-tabs > button > span {
+ pointer-events: none;
+}
+
+.webmention-likes,
+.webmention-reposts {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.webmentions .reactions ul {
+ padding: 0;
+}
+
+.webmentions .reactions ul > li {
+ margin-right: 1rem;
+}
+
+.webmentions .reactions ul > li::before {
+ display: none;
+}
+
+.webmentions a.webmention-author {
+ text-decoration: none;
+}
+
+.webmentions a.webmention-author:hover {
+ text-decoration: underline;
+}
+
+.webmention-author .webmention-author-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+ background: grey;
+}
+
+.webmention-meta {
+ font-size: 0.875rem;
+ color: #515660;
+}
+
+.webmention-meta a.webmention-source {
+ color: #515660;
+}
+
+.webmention-replies-item,
+.webmention-mentions-item {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ padding: 1rem 0 1rem 60px;
+}
+
+.webmention-replies-item .webmention-author .webmention-author-avatar,
+.webmention-mentions-item .webmention-author .webmention-author-avatar {
+ position: absolute;
+ top: 1rem;
+ left: 0;
+}
+
/* Needed for Safari to hide the index properly */
@media print {
.main {
diff --git a/src/static/js/webmentions.js b/src/static/js/webmentions.js
new file mode 100644
index 00000000000..10fe7db5ead
--- /dev/null
+++ b/src/static/js/webmentions.js
@@ -0,0 +1,212 @@
+// Code related to parsing and showing webmentions
+
+// Gets the webmentions json data from current URL
+async function getWebmentions(targetURL) {
+ const apiURL = `https://webmention.io/api/mentions.jf2?perPage=500&target=${targetURL}&sort-dir=up`;
+ let mentions = [];
+ try {
+ const response = await window.fetch(apiURL);
+ if (response.status >= 200 && response.status < 300) {
+ const json = await response.json();
+ mentions = json.children;
+ } else {
+ console.error("Could not parse response", response.statusText);
+ gtag('event', 'error', { 'event_category': 'webmentions.js', 'event_label': response.statusText, 'value': 1 });
+ }
+ } catch(error) {
+ console.error("Request failed", error);
+ gtag('event', 'error', { 'event_category': 'webmentions.js', 'event_label': error, 'value': 1 });
+ }
+ return mentions;
+}
+
+// Format published date into human readable form
+function formatDate(dateString){
+ const options = { year: "numeric", month: "long", day: "numeric" }
+ return new Date(dateString).toLocaleTimeString(undefined, options)
+}
+
+// Parse webmentions for individual category
+function parseMentions(webmentions, mentionType) {
+ let filteredMentions = []
+ webmentions.forEach(function(mention){
+ if (mention["wm-property"] == mentionType) {
+ filteredMentions.push(mention)
+ }
+ });
+ return filteredMentions;
+}
+
+// Renders webmention into different sections, based on the type
+function renderReactions(webmentions, reactionType, wmProperty) {
+ // Process webmentions
+ const reactions = parseMentions(webmentions, wmProperty);
+ if (!reactions.length) {
+ return;
+ }
+
+ // Add the count to the reaction tab
+ document.querySelector(`#${reactionType}-count`).textContent = reactions.length;
+ const reactionLabel = document.querySelector(`#${reactionType}-label`);
+ if (reactions && reactions.length ===1) {
+ reactionLabel.textContent = reactionLabel.getAttribute("data-singular");
+ }
+
+ // Render logic for the reaction types
+ const webmentionReactionsList = document.createElement("ul");
+ webmentionReactionsList.setAttribute("class", `webmention-${reactionType}`);
+ reactions.forEach(function(reaction) {
+ const reactionLi = document.createElement("li");
+ reactionLi.setAttribute("class", `webmention-${reactionType}-item`);
+
+ const reactionA = document.createElement("a");
+ reactionA.setAttribute("class", "webmention-author");
+ reactionA.setAttribute("href", reaction["url"]);
+ reactionA.setAttribute("title", reaction["author"]["name"]);
+ reactionA.setAttribute("aria-label", reaction["author"]["name"]);
+
+ const reactionIMG = document.createElement("img");
+ reactionIMG.setAttribute("class", "webmention-author-avatar");
+ reactionIMG.setAttribute("src", reaction["author"]["photo"]);
+ reactionIMG.setAttribute("alt", reaction["author"]["name"]);
+ reactionIMG.setAttribute("loading", "lazy");
+ reactionIMG.setAttribute("width", "60");
+ reactionIMG.setAttribute("height", "60");
+
+ reactionA.appendChild(reactionIMG);
+
+ // Replies and mentions have some extra HTML
+ const reactionDivContent = document.createElement("div");
+ const reactionDivMeta = document.createElement("div");
+ if (reactionType === "replies" || reactionType === "mentions") {
+ const reactionSTRONG = document.createElement("strong");
+ reactionSTRONG.setAttribute("class", "webmention-author-name");
+ reactionSTRONG.textContent = reaction["author"]["name"];
+ reactionA.appendChild(reactionSTRONG);
+
+ reactionDivContent.setAttribute("class", "webmention-content");
+ reactionDivContent.textContent = reaction["content"]["text"];
+
+ reactionDivMeta.setAttribute("class", "webmention-meta");
+
+ const reactionTime = document.createElement("time");
+ reactionTime.setAttribute("class", "webmention-pub-date");
+ reactionTime.setAttribute("datetime", reaction["published"]);
+ reactionTime.textContent = formatDate(reaction["published"]);
+
+ const reactionSpan = document.createElement("span");
+ reactionSpan.setAttribute("class", "webmention-divider");
+ reactionSpan.setAttribute("aria-hidden", "true");
+ reactionSpan.textContent = " ⋅ ";
+
+ const reactionASource = document.createElement("a");
+ reactionASource.setAttribute("class", "webmention-source");
+ reactionASource.setAttribute("href", reaction["url"]);
+ reactionASource.textContent = document.querySelector(".reactions").getAttribute("data-source");
+
+ reactionDivMeta.appendChild(reactionTime);
+ reactionDivMeta.appendChild(reactionSpan);
+ reactionDivMeta.appendChild(reactionASource);
+ }
+
+ reactionLi.appendChild(reactionA);
+ if (reactionType === "replies" || reactionType === "mentions") {
+ reactionLi.appendChild(reactionDivContent);
+ reactionLi.appendChild(reactionDivMeta);
+ }
+ webmentionReactionsList.appendChild(reactionLi);
+ });
+ document.querySelector(`#${reactionType}-panel`).appendChild(webmentionReactionsList);
+}
+
+// Parses and renders mentions into likes, reposts, replies and mentions
+function renderWebmentions(webmentions) {
+ if (!webmentions.length) {
+ return;
+ }
+
+ renderReactions(webmentions, "likes", "like-of");
+ renderReactions(webmentions, "reposts", "repost-of");
+ renderReactions(webmentions, "replies", "in-reply-to");
+ renderReactions(webmentions, "mentions", "mention-of");
+}
+
+// Process webmention promise
+function processWebmentions(targetURL) {
+ getWebmentions(targetURL)
+ .then(webmentions => renderWebmentions(webmentions))
+ .catch(e => {
+ console.error(e)
+ gtag('event', 'error', { 'event_category': 'webmentions.js', 'event_label': e, 'value': 1 })
+ })
+}
+
+// Change tabs for webmentions UI
+function changeTabs(target) {
+ const parent = target.parentNode;
+ const grandparent = parent.parentNode;
+
+ // Remove all current selected tabs
+ parent
+ .querySelectorAll('[aria-selected="true"]')
+ .forEach(t => t.setAttribute("aria-selected", false));
+
+ // Set this tab as selected
+ target.setAttribute("aria-selected", true);
+
+ // Hide all tab panels
+ grandparent
+ .querySelectorAll('[role="tabpanel"]')
+ .forEach(p => p.setAttribute("hidden", true));
+
+ // Show the selected panel
+ grandparent.parentNode
+ .querySelector(`#${target.getAttribute("aria-controls")}`)
+ .removeAttribute("hidden");
+}
+
+function addTabListeners() {
+ const tabs = document.querySelectorAll('.reactions [role="tab"]');
+ const tabList = document.querySelector('.reactions [role="tablist"]');
+
+ if (!tabs || !tabList) {
+ return;
+ }
+ // Add a click event handler to each tab
+ tabs.forEach(tab => {
+ tab.addEventListener("click", function(e){changeTabs(e.target)});
+ });
+
+ // Enable arrow navigation between tabs in the tab list
+ let tabFocus = 0;
+
+ tabList.addEventListener("keydown", e => {
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
+ tabs[tabFocus].setAttribute("tabindex", -1);
+ // Move right
+ if (e.key === "ArrowRight") {
+ tabFocus++;
+ // If we're at the end, go to the start
+ if (tabFocus >= tabs.length) {
+ tabFocus = 0;
+ }
+ // Move left
+ } else if (e.key === "ArrowLeft") {
+ tabFocus--;
+ // If we're at the start, move to the end
+ if (tabFocus < 0) {
+ tabFocus = tabs.length - 1;
+ }
+ }
+
+ tabs[tabFocus].setAttribute("tabindex", 0);
+ tabs[tabFocus].focus();
+ changeTabs(tabs[tabFocus]);
+ }
+ });
+}
+
+
+addTabListeners();
+const BASE_URL = "https://almanac.httparchive.org";
+processWebmentions(BASE_URL + window.location.pathname);
diff --git a/src/templates/base/base_chapter.html b/src/templates/base/base_chapter.html
index ccc24f0dc01..833dbe7809c 100644
--- a/src/templates/base/base_chapter.html
+++ b/src/templates/base/base_chapter.html
@@ -58,6 +58,7 @@
{% endif %}
{{ super() }}
+
{% endblock %}
{# Calls to action for readers who want to engage more with this chapter. #}
@@ -149,6 +150,40 @@
{% endif %}
{% endmacro %}
+{% macro render_webmentions() %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endmacro %}
+
{% macro render_authors() %}
{% for author in metadata.get('authors') %}
{% if loop.index == 1 %}
@@ -330,6 +365,11 @@
{{ render_actions() }}
+ {% if year | int >= 2021 %}
+
+ {{ render_webmentions() }}
+
+ {% endif %}
@@ -344,6 +384,15 @@
+ {% elif year | int >= 2021 %}
+
+
+
{% endif %}