fix: robust mastodon dedup with MutationObserver + URL normalization
Build & Deploy / build-and-deploy (push) Successful in 1m59s

- MutationObserver set up before fetching so webmention NEW cards added after
  the dedup pass are also removed immediately (race condition fix)
- Normalize /@handle and /users/handle URL variants to same form before
  comparing — covers Mastodon instances that use either format
- Add wm-owner-reply-slot div + Reply link to bsky/mastodon Alpine cards
  to match build-time webmention card structure
This commit is contained in:
svemagie
2026-04-20 18:38:53 +02:00
parent b5ed4c08a0
commit 3da836fab8
2 changed files with 41 additions and 4 deletions
+8
View File
@@ -228,8 +228,12 @@
</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 %}
@@ -258,8 +262,12 @@
</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 %}
+33 -4
View File
@@ -8,7 +8,34 @@ document.addEventListener("alpine:init", () => {
mastodonReplies: [],
loading: true,
// Normalized mastodon author URLs for dedup — populated after _fetchMastodon
_mastodonAuthorNormed: new Set(),
// Normalize /@handle and /users/handle to the same form for URL comparison
_normalizeActorUrl(url) {
return (url || "").replace(/\/(users\/|@)/, "/").replace(/\/$/, "").toLowerCase();
},
async init() {
// Watch for webmention cards added AFTER our dedup pass (race condition with webmentions.js)
const repliesList = this.$el.querySelector("ul");
if (repliesList) {
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === 1 && node.dataset.authorUrl) {
if (this._mastodonAuthorNormed.has(this._normalizeActorUrl(node.dataset.authorUrl))) {
node.remove();
}
}
}
}
});
observer.observe(repliesList, { childList: true });
// Disconnect after page is hidden — observer is no longer needed
document.addEventListener("visibilitychange", () => observer.disconnect(), { once: true });
}
const tasks = [];
if (bskyUrl) tasks.push(this._fetchBsky(bskyUrl));
if (mastodonInstance && apIdentity) tasks.push(this._fetchMastodon(postUrl, mastodonInstance, apIdentity));
@@ -98,11 +125,13 @@ document.addEventListener("alpine:init", () => {
favourites: s.favourites_count || 0,
}));
// Remove webmention cards (build-time or client-fetched) that duplicate mastodon replies.
// Webmention cards have data-author-url; Alpine x-for cards do not.
const mastodonAuthorUrls = new Set(this.mastodonReplies.map((r) => r.author.url));
// Build normalized author URL set for dedup (covers /@handle and /users/handle variants)
this._mastodonAuthorNormed = new Set(
this.mastodonReplies.map((r) => this._normalizeActorUrl(r.author.url))
);
// Remove webmention cards (build-time or NEW) that duplicate mastodon replies
document.querySelectorAll(".webmention-replies li[data-author-url]").forEach((li) => {
if (mastodonAuthorUrls.has(li.dataset.authorUrl)) li.remove();
if (this._mastodonAuthorNormed.has(this._normalizeActorUrl(li.dataset.authorUrl))) li.remove();
});
} catch {
// fail silently