Files
indiekit-blog/js/social-threads.js
svemagie 3da836fab8
Build & Deploy / build-and-deploy (push) Successful in 1m59s
fix: robust mastodon dedup with MutationObserver + URL normalization
- 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
2026-04-20 18:38:53 +02:00

171 lines
6.6 KiB
JavaScript

/**
* Social Threads — Alpine.js unified component for Bluesky + Mastodon/AP replies
* Used in webmentions.njk Replies section to fetch live thread data from both platforms.
*/
document.addEventListener("alpine:init", () => {
Alpine.data("socialReplies", (bskyUrl, postUrl, mastodonInstance, apIdentity) => ({
bskyReplies: [],
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));
await Promise.allSettled(tasks);
this.loading = false;
},
// ── Bluesky / AT Protocol ──────────────────────────────────────────────
async _fetchBsky(url) {
try {
const match = url.match(/bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/);
if (!match) return;
const [, actor, rkey] = match;
const atUri = `at://${actor}/app.bsky.feed.post/${rkey}`;
const res = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(atUri)}&depth=10`
);
if (!res.ok) return;
const data = await res.json();
this.bskyReplies = this._flattenBsky(data.thread, 0);
} catch {
// fail silently — no replies shown
}
},
_flattenBsky(node, depth) {
const out = [];
for (const r of node.replies || []) {
if (r.$type !== "app.bsky.feed.defs#threadViewPost") continue;
const p = r.post;
const rkey = p.uri.split("/").pop();
out.push({
depth: Math.min(depth, 3),
uri: p.uri,
url: `https://bsky.app/profile/${p.author.handle}/post/${rkey}`,
author: {
name: p.author.displayName || p.author.handle,
handle: p.author.handle,
avatar: p.author.avatar || "",
url: `https://bsky.app/profile/${p.author.handle}`,
},
text: p.record.text,
published: p.record.createdAt,
likeCount: p.likeCount || 0,
});
out.push(...this._flattenBsky(r, depth + 1));
}
return out;
},
// ── Mastodon / ActivityPub ─────────────────────────────────────────────
async _fetchMastodon(postUrl, instance, apIdentity) {
try {
// Parse "@handle@domain" from apIdentity
const m = apIdentity.match(/^@?([^@]+)@(.+)$/);
if (!m) return;
const [, handle, domain] = m;
const acct = encodeURIComponent(`${handle}@${domain}`);
// Look up the blog's AP actor on the Mastodon instance
const lookupRes = await fetch(`https://${instance}/api/v1/accounts/lookup?acct=${acct}`);
if (!lookupRes.ok) return;
const account = await lookupRes.json();
// Find the status matching the post URL
const statusId = await this._findMastodonStatus(account.id, postUrl, instance);
if (!statusId) return;
// Fetch thread context
const ctxRes = await fetch(`https://${instance}/api/v1/statuses/${statusId}/context`);
if (!ctxRes.ok) return;
const ctx = await ctxRes.json();
this.mastodonReplies = (ctx.descendants || []).map((s) => ({
id: s.id,
url: s.url,
author: {
name: s.account.display_name || s.account.username,
handle: s.account.acct,
avatar: s.account.avatar || "",
url: s.account.url,
},
content: s.content, // HTML from Mastodon
published: s.created_at,
favourites: s.favourites_count || 0,
}));
// 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 (this._mastodonAuthorNormed.has(this._normalizeActorUrl(li.dataset.authorUrl))) li.remove();
});
} catch {
// fail silently
}
},
async _findMastodonStatus(accountId, postUrl, instance) {
let maxId = null;
for (let page = 0; page < 10; page++) {
const qs = `limit=40${maxId ? `&max_id=${maxId}` : ""}`;
const res = await fetch(`https://${instance}/api/v1/accounts/${accountId}/statuses?${qs}`);
if (!res.ok) break;
const statuses = await res.json();
if (!Array.isArray(statuses) || !statuses.length) break;
const match = statuses.find((s) => s.url === postUrl);
if (match) return match.id;
maxId = statuses[statuses.length - 1].id;
}
return null;
},
// ── Utilities ─────────────────────────────────────────────────────────
formatDate(dateStr) {
try {
return new Date(dateStr).toLocaleDateString("de-DE", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return dateStr;
}
},
}));
});