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>
<div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none" <div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none"
x-text="reply.text"></div> 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> </div>
<div class="wm-owner-reply-slot ml-13 mt-2"></div>
</li> </li>
</template> </template>
{% endif %} {% endif %}
@@ -258,8 +262,12 @@
</div> </div>
<div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none" <div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none"
x-html="reply.content"></div> 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> </div>
<div class="wm-owner-reply-slot ml-13 mt-2"></div>
</li> </li>
</template> </template>
{% endif %} {% endif %}
+33 -4
View File
@@ -8,7 +8,34 @@ document.addEventListener("alpine:init", () => {
mastodonReplies: [], mastodonReplies: [],
loading: true, 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() { 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 = []; const tasks = [];
if (bskyUrl) tasks.push(this._fetchBsky(bskyUrl)); if (bskyUrl) tasks.push(this._fetchBsky(bskyUrl));
if (mastodonInstance && apIdentity) tasks.push(this._fetchMastodon(postUrl, mastodonInstance, apIdentity)); if (mastodonInstance && apIdentity) tasks.push(this._fetchMastodon(postUrl, mastodonInstance, apIdentity));
@@ -98,11 +125,13 @@ document.addEventListener("alpine:init", () => {
favourites: s.favourites_count || 0, favourites: s.favourites_count || 0,
})); }));
// Remove webmention cards (build-time or client-fetched) that duplicate mastodon replies. // Build normalized author URL set for dedup (covers /@handle and /users/handle variants)
// Webmention cards have data-author-url; Alpine x-for cards do not. this._mastodonAuthorNormed = new Set(
const mastodonAuthorUrls = new Set(this.mastodonReplies.map((r) => r.author.url)); 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) => { 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 { } catch {
// fail silently // fail silently