feat: add fediverse remote interaction button and syndication platform buttons

Add a Fediverse button to the "Also on" footer for posts syndicated via
self-hosted ActivityPub. Clicking it redirects users to their own instance
via authorize_interaction so they can like/boost/reply natively. Instance
is stored in localStorage for repeat visits, with a modal for first-time
entry and Shift+click to change.

Also adds branded syndication buttons for LinkedIn and IndieNews, and
replaces the heuristic Mastodon URL detection with exact matching against
the configured MASTODON_INSTANCE.
This commit is contained in:
Ricardo
2026-02-22 15:56:01 +01:00
parent c70b51b499
commit 8597856589
3 changed files with 201 additions and 4 deletions
+1
View File
@@ -75,6 +75,7 @@
<script src="https://cdn.jsdelivr.net/npm/lite-youtube-embed@0.3.2/src/lite-yt-embed.min.js" defer></script>
{# Alpine.js components — MUST load before Alpine core (Alpine.data() registration via alpine:init) #}
<script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script>
<script src="/js/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak] { display: none !important; }</style>
+93 -4
View File
@@ -72,20 +72,95 @@ withBlogSidebar: true
{% endif %}
{# Syndication Footer - shows where this post was also published #}
{# Skip syndication URLs that point back to our own site (self-hosted AP) #}
{# Separate self-hosted AP URLs from external syndication targets #}
{% set externalSyndication = [] %}
{% set selfHostedApUrl = "" %}
{% if syndication %}
{% for url in syndication %}
{% if url.indexOf(site.url) != 0 %}
{% if url.indexOf(site.url) == 0 %}
{% set selfHostedApUrl = url %}
{% else %}
{% set externalSyndication = (externalSyndication.push(url), externalSyndication) %}
{% endif %}
{% endfor %}
{% endif %}
{% if externalSyndication.length %}
{% if externalSyndication.length or selfHostedApUrl %}
<footer class="post-footer mt-8 pt-6 border-t border-surface-200 dark:border-surface-700">
<div class="flex flex-wrap items-center gap-4">
<span class="text-sm text-surface-500 dark:text-surface-400">Also on:</span>
<div class="flex flex-wrap gap-3">
{# Fediverse remote interaction button (self-hosted ActivityPub) #}
{% if selfHostedApUrl %}
<span x-data="fediverseInteract('{{ site.url }}{{ page.url }}')" class="inline-flex">
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#a730b8]/10 text-[#a730b8] hover:bg-[#a730b8]/20 transition-colors text-sm font-medium cursor-pointer"
href="{{ site.url }}{{ page.url }}"
rel="syndication"
title="Interact from your fediverse instance (Shift+click to change)"
@click="handleClick($event)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
<span>Fediverse</span>
</a>
{# Modal overlay for instance entry #}
<template x-if="showModal">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4" @keydown.escape.window="showModal = false">
{# Backdrop #}
<div class="fixed inset-0 bg-black/40"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showModal = false"></div>
{# Panel #}
<div class="relative bg-white dark:bg-surface-800 rounded-xl shadow-xl w-full max-w-sm p-6"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.outside="showModal = false">
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-1">Fediverse Interaction</h3>
<p class="text-sm text-surface-500 dark:text-surface-400 mb-4">Enter your instance to like, boost, or reply.</p>
<div class="relative">
<input x-ref="instanceInput"
x-model="instance"
@input="filterSuggestions()"
@keydown.enter.prevent="confirm()"
type="text"
placeholder="mastodon.social"
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-700 text-surface-900 dark:text-surface-100 placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-[#a730b8] focus:border-transparent text-sm">
{# Autocomplete suggestions #}
<ul x-show="suggestions.length > 0"
class="absolute z-10 left-0 right-0 mt-1 bg-white dark:bg-surface-700 border border-surface-200 dark:border-surface-600 rounded-lg shadow-lg max-h-48 overflow-y-auto">
<template x-for="domain in suggestions" :key="domain">
<li @click="selectSuggestion(domain)"
x-text="domain"
class="px-3 py-2 text-sm text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-600 cursor-pointer"></li>
</template>
</ul>
</div>
<div class="flex gap-3 mt-4">
<button @click="showModal = false"
class="flex-1 px-4 py-2 text-sm font-medium text-surface-600 dark:text-surface-300 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 rounded-lg transition-colors">
Cancel
</button>
<button @click="confirm()"
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-[#a730b8] hover:bg-[#a730b8]/80 rounded-lg transition-colors">
Go
</button>
</div>
</div>
</div>
</template>
</span>
{% endif %}
{# External syndication buttons #}
{% for url in externalSyndication %}
{% if "bsky.app" in url or "bluesky" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#0085ff]/10 text-[#0085ff] hover:bg-[#0085ff]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on Bluesky">
@@ -94,13 +169,27 @@ withBlogSidebar: true
</svg>
<span>Bluesky</span>
</a>
{% elif "mstdn" in url or "mastodon" in url or "social" in url %}
{% elif site.feeds.mastodon.instance and site.feeds.mastodon.instance in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#6364ff]/10 text-[#6364ff] hover:bg-[#6364ff]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on Mastodon">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
</svg>
<span>Mastodon</span>
</a>
{% elif "linkedin.com" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#0a66c2]/10 text-[#0a66c2] hover:bg-[#0a66c2]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on LinkedIn">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
<span>LinkedIn</span>
</a>
{% elif "news.indieweb.org" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#ff5c00]/10 text-[#ff5c00] hover:bg-[#ff5c00]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on IndieNews">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><line x1="10" y1="6" x2="18" y2="6"/><line x1="10" y1="10" x2="18" y2="10"/><line x1="10" y1="14" x2="14" y2="14"/>
</svg>
<span>IndieNews</span>
</a>
{% else %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+107
View File
@@ -0,0 +1,107 @@
/**
* Fediverse remote interaction component (Alpine.js)
* Enables users to like/boost/reply from their own fediverse instance
* via the authorize_interaction endpoint.
*
* Registered via Alpine.data() so the component is available
* regardless of script loading order.
*/
document.addEventListener("alpine:init", () => {
Alpine.data("fediverseInteract", (postUrl) => ({
postUrl,
showModal: false,
instance: "",
suggestions: [],
allNodes: null,
loading: false,
handleClick(event) {
event.preventDefault();
const saved = localStorage.getItem("fediverse_instance");
if (saved && !event.shiftKey) {
this.redirectToInstance(saved);
return;
}
this.openModal(saved);
},
openModal(prefill) {
this.instance = prefill || "";
this.suggestions = [];
this.showModal = true;
this.fetchNodes();
this.$nextTick(() => {
const input = this.$refs.instanceInput;
if (input) input.focus();
});
},
async fetchNodes() {
const cached = sessionStorage.getItem("fediverse_nodes");
if (cached) {
try {
this.allNodes = JSON.parse(cached);
this.filterSuggestions();
return;
} catch {
// Corrupted cache, refetch
}
}
this.loading = true;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch("https://nodes.fediverse.party/nodes.json", {
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
this.allNodes = await res.json();
sessionStorage.setItem(
"fediverse_nodes",
JSON.stringify(this.allNodes),
);
this.filterSuggestions();
}
} catch {
// Network error or timeout — autocomplete unavailable
} finally {
this.loading = false;
}
},
filterSuggestions() {
const query = this.instance.trim().toLowerCase();
if (!query || !this.allNodes) {
this.suggestions = [];
return;
}
this.suggestions = this.allNodes
.filter((node) => node.toLowerCase().includes(query))
.slice(0, 8);
},
selectSuggestion(domain) {
this.instance = domain;
this.suggestions = [];
},
confirm() {
let domain = this.instance.trim();
if (!domain) return;
// Strip protocol and trailing slashes
domain = domain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
localStorage.setItem("fediverse_instance", domain);
this.showModal = false;
this.redirectToInstance(domain);
},
redirectToInstance(domain) {
const url = `https://${domain}/authorize_interaction?uri=${encodeURIComponent(this.postUrl)}`;
window.location.href = url;
},
}));
});