diff --git a/js/social-threads.js b/js/social-threads.js new file mode 100644 index 0000000..1a84836 --- /dev/null +++ b/js/social-threads.js @@ -0,0 +1,134 @@ +/** + * 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, + + async init() { + 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, + })); + } 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; + } + }, + })); +});