feat: add socialReplies Alpine component for Bluesky + Mastodon threads
Build & Deploy / build-and-deploy (push) Has been cancelled
Build & Deploy / build-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user