/** * 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; } }, })); });