Files
svemagie cf917a9010
Build & Deploy / build-and-deploy (push) Successful in 2m1s
Fix Mastodon/Bsky duplicate replies in webmentions.js
webmentions.js was adding NEW cards for Mastodon replies even when
Alpine had already rendered them, because the client-side filter didn't
mirror the Nunjucks build-time fed.brid.gy exclusion rule.

Pass mastodon-active + bsky-url as data attributes on the webmentions
container div, then filter out fed.brid.gy sourced Mastodon replies
and bsky.app/brid.gy/bluesky Bluesky replies from regularItems when
the corresponding Alpine component is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:20:16 +02:00

335 lines
18 KiB
Plaintext

{# Webmentions Component #}
{# Displays likes, reposts, and replies for a post #}
{# Also checks legacy URLs from micro.blog and old blog for historical webmentions #}
{# Client-side JS supplements build-time data with real-time fetches #}
{% set mentions = webmentions | webmentionsForUrl(page.url, urlAliases, conversationMentions) %}
{% set absoluteUrl = site.url + page.url %}
{% set buildTimestamp = "" | timestamp %}
{# Data container for client-side JS to fetch new webmentions #}
<div data-webmentions
data-target="{{ absoluteUrl }}"
data-domain="{{ site.webmentions.domain }}"
data-buildtime="{{ buildTimestamp }}"
data-mastodon-active="{{ 'true' if mastodonInstance else '' }}"
data-bsky-url="{{ bskySyndUrl }}"
class="hidden"></div>
{# Detect Bluesky syndication URL here — needed to show section even without webmentions #}
{% set bskySyndUrl = "" %}
{% if syndication %}
{% for url in syndication %}
{% if "bsky.app" in url %}{% set bskySyndUrl = url %}{% endif %}
{% endfor %}
{% endif %}
{% set mastodonInstance = site.feeds.mastodon.instance %}
{% if mentions.length or bskySyndUrl or mastodonInstance %}
<section class="webmentions mt-8 pt-8 border-t border-surface-200 dark:border-surface-700" id="webmentions">
<h2 class="text-lg font-semibold text-surface-700 dark:text-surface-300 mb-6">
Interactions ({{ mentions.length }})
</h2>
{# Likes #}
{% set likes = mentions | webmentionsByType('likes') %}
{% if likes.length %}
<div class="webmention-likes mb-6">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ likes.length }} Like{% if likes.length != 1 %}s{% endif %}
</h3>
<is-land on:visible>
<ul class="facepile" role="list">
{% for like in likes %}
<li class="inline" data-wm-source="{{ like['wm-source'] if like['wm-source'] else '' }}" data-author-url="{{ like.author.url }}">
<a href="{{ like.author.url }}"
class="facepile-avatar"
aria-label="{{ like.author.name }} (opens in new tab)"
target="_blank"
rel="noopener">
<img
src="{{ like.author.photo or '/images/default-avatar.svg' }}"
alt=""
class="w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900"
loading="lazy"
>
</a>
</li>
{% endfor %}
</ul>
</is-land>
</div>
{% endif %}
{# Reposts #}
{% set reposts = mentions | webmentionsByType('reposts') %}
{% if reposts.length %}
<div class="webmention-reposts mb-6">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ reposts.length }} Repost{% if reposts.length != 1 %}s{% endif %}
</h3>
<is-land on:visible>
<ul class="facepile" role="list">
{% for repost in reposts %}
<li class="inline" data-wm-source="{{ repost['wm-source'] if repost['wm-source'] else '' }}" data-author-url="{{ repost.author.url }}">
<a href="{{ repost.author.url }}"
class="facepile-avatar"
aria-label="{{ repost.author.name }} (opens in new tab)"
target="_blank"
rel="noopener">
<img
src="{{ repost.author.photo or '/images/default-avatar.svg' }}"
alt=""
class="w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900"
loading="lazy"
>
</a>
</li>
{% endfor %}
</ul>
</is-land>
</div>
{% endif %}
{# Bookmarks #}
{% set bookmarks = mentions | webmentionsByType('bookmarks') %}
{% if bookmarks.length %}
<div class="webmention-bookmarks mb-6">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ bookmarks.length }} Bookmark{% if bookmarks.length != 1 %}s{% endif %}
</h3>
<is-land on:visible>
<ul class="facepile" role="list">
{% for bookmark in bookmarks %}
<li class="inline" data-wm-source="{{ bookmark['wm-source'] if bookmark['wm-source'] else '' }}" data-author-url="{{ bookmark.author.url }}">
<a href="{{ bookmark.author.url }}"
class="facepile-avatar"
aria-label="{{ bookmark.author.name }} (opens in new tab)"
target="_blank"
rel="noopener">
<img
src="{{ bookmark.author.photo or '/images/default-avatar.svg' }}"
alt=""
class="w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900"
loading="lazy"
>
</a>
</li>
{% endfor %}
</ul>
</is-land>
</div>
{% endif %}
{# Replies — webmention replies merged with Bluesky + Mastodon/AP threads (deduplicated) #}
{% set wm_replies = mentions | webmentionsByType('replies') %}
{# Activate Mastodon/AP thread fetching when instance is configured #}
{% set apIdentity = site.fediverseCreator %}
{% set mastodonActive = mastodonInstance %}
{% set replyComponentActive = bskySyndUrl or mastodonActive %}
{# Filter: remove Bluesky-sourced webmentions if bskySyndUrl; remove fed.brid.gy webmentions if mastodonActive #}
{% set filtered_replies = [] %}
{% for reply in wm_replies %}
{% set src = reply['wm-source'] | default('') %}
{% set includeReply = true %}
{% if bskySyndUrl and ('bsky.app' in src or 'brid.gy' in src or 'bridgy' in src or 'bsky.app' in (reply.author.url | default(''))) %}{% set includeReply = false %}{% endif %}
{% if mastodonActive and 'fed.brid.gy' in src %}{% set includeReply = false %}{% endif %}
{% if includeReply %}
{% set filtered_replies = (filtered_replies.push(reply), filtered_replies) %}
{% endif %}
{% endfor %}
{% if filtered_replies.length or replyComponentActive %}
{% set wmRepliesCount = filtered_replies.length %}
<div class="webmention-replies" x-cloak {% if replyComponentActive %}x-show="{{ wmRepliesCount }} + bskyReplies.length + mastodonReplies.length > 0" x-data="socialReplies('{{ bskySyndUrl }}', '{{ absoluteUrl }}', '{{ mastodonInstance if mastodonActive else "" }}', '{{ apIdentity if mastodonActive else "" }}')"{% endif %}>
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-4"
{% if replyComponentActive %}x-text="({{ wmRepliesCount }} + bskyReplies.length + mastodonReplies.length) + ' Repl' + (({{ wmRepliesCount }} + bskyReplies.length + mastodonReplies.length !== 1) ? 'ies' : 'y')"{% endif %}>
{% if not replyComponentActive %}{{ wmRepliesCount }} Repl{% if wmRepliesCount != 1 %}ies{% else %}y{% endif %}{% endif %}
</h3>
<ul class="space-y-4">
{% for reply in filtered_replies %}
<li class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm"
data-wm-url="{{ reply.url }}"
data-wm-source="{{ reply['wm-source'] if reply['wm-source'] else '' }}"
data-author-url="{{ reply.author.url }}">
<div class="flex gap-3">
<a href="{{ reply.author.url }}" target="_blank" rel="noopener">
<img
src="{{ reply.author.photo or '/images/default-avatar.svg' }}"
alt="{{ reply.author.name }}"
class="w-10 h-10 rounded-full"
loading="lazy"
>
</a>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<a href="{{ reply.author.url }}"
class="font-semibold text-surface-900 dark:text-surface-100 hover:underline"
target="_blank"
rel="noopener">
{{ reply.author.name }}
</a>
<span class="wm-provenance-badge" data-detect="true"></span>
<a href="{{ reply.url }}"
class="text-xs text-surface-600 dark:text-surface-400 hover:underline"
target="_blank"
rel="noopener">
<time class="font-mono" datetime="{{ reply.published }}">
{{ reply.published | date("MMM d, yyyy") }}
</time>
</a>
</div>
<div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none">
{{ reply.content.html | safe if reply.content.html else reply.content.text }}
</div>
{% set replySource = reply['wm-source'] | default('', true) %}
{% set replyAuthorUrl = reply.author.url | default('', true) %}
{% set buildPlatform = 'webmention' %}
{% if 'bsky.app' in replyAuthorUrl or ('brid.gy/' in replySource and '/bluesky/' in replySource) %}
{% set buildPlatform = 'bluesky' %}
{% elif 'brid.gy/' in replySource and '/mastodon/' in replySource %}
{% set buildPlatform = 'mastodon' %}
{% elif 'fed.brid.gy' in replySource %}
{% set buildPlatform = 'activitypub' %}
{% endif %}
<button class="wm-reply-btn hidden text-xs text-primary-600 dark:text-primary-400 hover:underline mt-2"
data-reply-url="{{ reply.url }}"
data-platform="{{ buildPlatform }}">
Reply
</button>
</div>
</div>
<div class="wm-owner-reply-slot ml-13 mt-2"></div>
</li>
{% endfor %}
{% if bskySyndUrl %}
<template x-for="reply in bskyReplies" :key="reply.uri">
<li class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm"
:class="reply.depth > 0 ? 'ml-4 sm:ml-8' : ''">
<div class="flex gap-3">
<a :href="reply.author.url" target="_blank" rel="noopener" class="flex-shrink-0">
<img :src="reply.author.avatar || '/images/default-avatar.svg'"
:alt="reply.author.name"
class="w-10 h-10 rounded-full"
loading="lazy">
</a>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<a :href="reply.author.url"
class="font-semibold text-surface-900 dark:text-surface-100 hover:underline"
target="_blank" rel="noopener"
x-text="reply.author.name"></a>
<span class="wm-provenance-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400 rounded-full" title="Bluesky"><svg class="w-2 h-2 inline-block" viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg></span>
<a :href="reply.url"
class="text-xs text-surface-600 dark:text-surface-400 hover:underline"
target="_blank" rel="noopener">
<time :datetime="reply.published" x-text="formatDate(reply.published)"></time>
</a>
</div>
<div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none"
x-text="reply.text"></div>
<a :href="reply.url"
class="wm-reply-btn hidden text-xs text-primary-600 dark:text-primary-400 hover:underline mt-2"
target="_blank" rel="noopener">Reply on Bluesky</a>
</div>
</div>
<div class="wm-owner-reply-slot ml-13 mt-2"></div>
</li>
</template>
{% endif %}
{% if mastodonActive %}
<template x-for="reply in mastodonReplies" :key="reply.id">
<li class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
<div class="flex gap-3">
<a :href="reply.author.url" target="_blank" rel="noopener" class="flex-shrink-0">
<img :src="reply.author.avatar || '/images/default-avatar.svg'"
:alt="reply.author.name"
class="w-10 h-10 rounded-full"
loading="lazy">
</a>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<a :href="reply.author.url"
class="font-semibold text-surface-900 dark:text-surface-100 hover:underline"
target="_blank" rel="noopener"
x-text="reply.author.name"></a>
<span class="wm-provenance-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-full" title="Mastodon"><svg class="w-2 h-2 inline-block" viewBox="0 0 24 24" fill="currentColor"><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>
<a :href="reply.url"
class="text-xs text-surface-600 dark:text-surface-400 hover:underline"
target="_blank" rel="noopener">
<time :datetime="reply.published" x-text="formatDate(reply.published)"></time>
</a>
</div>
<div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none"
x-html="reply.content"></div>
<a :href="reply.url"
class="wm-reply-btn hidden text-xs text-primary-600 dark:text-primary-400 hover:underline mt-2"
target="_blank" rel="noopener">Reply on Mastodon</a>
</div>
</div>
<div class="wm-owner-reply-slot ml-13 mt-2"></div>
</li>
</template>
{% endif %}
</ul>
</div>
{% endif %}
{# Other mentions #}
{% set otherMentions = mentions | webmentionsByType('mentions') %}
{% if otherMentions.length %}
<div class="webmention-mentions mt-6">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ otherMentions.length }} Mention{% if otherMentions.length != 1 %}s{% endif %}
</h3>
<ul class="space-y-2 text-sm">
{% for mention in otherMentions %}
<li>
<a href="{{ mention.url }}"
class="text-accent-600 dark:text-accent-400 hover:underline"
target="_blank"
rel="noopener">
{{ mention.author.name }} mentioned this on <time class="font-mono" datetime="{{ mention.published }}">{{ mention.published | date("MMM d, yyyy") }}</time>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
{% endif %}
{# Webmention send form — collapsed by default #}
<details class="webmention-form mt-8">
<summary class="text-sm font-semibold text-surface-600 dark:text-surface-400 cursor-pointer hover:text-surface-700 dark:hover:text-surface-300 transition-colors list-none [&::-webkit-details-marker]:hidden flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 transition-transform [[open]>&]:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
Send a Webmention
</summary>
<div class="mt-3 p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
<p class="text-xs text-surface-600 dark:text-surface-400 mb-3">
Have you written a response to this post? Send a webmention by entering your post URL below.
</p>
<form action="https://webmention.io/{{ site.webmentions.domain }}/webmention" method="post" class="flex gap-2">
<input type="hidden" name="target" value="{{ site.url }}{{ page.url }}">
<label for="webmention-source" class="sr-only">Your post URL</label>
<input
id="webmention-source"
type="url"
name="source"
placeholder="https://your-site.com/response"
required
class="flex-1 px-3 py-2 text-sm bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded"
>
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-accent-600 hover:bg-accent-700 rounded transition-colors">
Send
</button>
</form>
</div>
</details>