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() %} +

+ {{ self.reactions() }} +

+
+
+ + + + +
+
+
+ + + +
+{% endmacro %} + {% macro render_authors() %} {% for author in metadata.get('authors') %} {% if loop.index == 1 %} @@ -330,6 +365,11 @@

+ {% if year | int >= 2021 %} + + {% endif %}
{{ render_authors() }}
@@ -344,6 +384,15 @@

{{ self.comments() }} + {% elif year | int >= 2021 %} +
+ + + {{ self.reactions() }} + +
{% endif %}