From 3da836fab81fc6a8c651436ed2e8f75efda05d8d Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:38:53 +0200 Subject: [PATCH] fix: robust mastodon dedup with MutationObserver + URL normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- _includes/components/webmentions.njk | 8 ++++++ js/social-threads.js | 37 +++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/_includes/components/webmentions.njk b/_includes/components/webmentions.njk index 8d49277..0105fbc 100644 --- a/_includes/components/webmentions.njk +++ b/_includes/components/webmentions.njk @@ -228,8 +228,12 @@
+ Reply on Bluesky + {% endif %} @@ -258,8 +262,12 @@ + Reply on Mastodon + {% endif %} diff --git a/js/social-threads.js b/js/social-threads.js index 9756522..b3ab76d 100644 --- a/js/social-threads.js +++ b/js/social-threads.js @@ -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