// © https://phosphoricons.com/ export const icons = { reblog: ``, favourite: ``, author: ``, // @ https://simpleicons.org/ mastodon: `Mastodon`, pleroma: `Pleroma`, bluesky: `Bluesky`, }; export default class SocialComments extends HTMLElement { comments = {}; async connectedCallback() { const lang = this.closest("[lang]")?.lang || navigator.language || "en"; this.dateTimeFormatter = new Intl.DateTimeFormat(lang, { dateStyle: "medium", timeStyle: "short", }); const mastodon = this.getAttribute("mastodon") || this.getAttribute("src"); const bluesky = this.getAttribute("bluesky"); await Promise.all([ mastodon && this.#fetchMastodon(new URL(mastodon)), bluesky && this.#fetchBluesky(new URL(bluesky)), ]); this.refresh(); } refresh() { const comments = [ ...this.comments.mastodon || [], ...this.comments.bluesky || [], ].sort( (a, b) => new Date(a.createdAt) - new Date(b.createdAt), ); if (comments.length) { this.innerHTML = ""; this.render(this, comments); } } async #fetchBluesky(url) { const { pathname } = url; const [, handle, rkey] = pathname.match( /\/profile\/([\w\.]+)\/post\/(\w+)/, ); if (!handle || !rkey) { return; } const options = { ttl: Number(this.getAttribute("cache") || 0), }; const didData = await fetchJSON( `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, options, ); const uri = `at://${didData.did}/app.bsky.feed.post/${rkey}`; this.comments.bluesky = dataFromBluesky( await fetchJSON( `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${uri}`, options, ), ); } async #fetchMastodon(url) { const { origin, pathname } = url; let id; const source = pathname.includes("/notice/") ? "pleroma" : "mastodon"; if (source === "pleroma") { [, id] = pathname.match(/^\/notice\/([^\/?#]+)/); } else { [, id] = pathname.match(/\/(\d+)$/); } if (!id) { return; } const token = this.getAttribute("token"); const options = { ttl: Number(this.getAttribute("cache") || 0), }; if (token) { options.headers = { Authorization: `Bearer ${token}`, }; } const user = url.pathname.split("/")[1]; const author = `${user}@${url.hostname}`; const comments = dataFromMastodon( await fetchJSON( new URL(`${origin}/api/v1/statuses/${id}/context`), options, ), author, source, ); this.comments.mastodon = comments.filter((comment) => comment.parent === id ); } render(container, replies) { const ul = document.createElement("ul"); for (const reply of replies) { const comment = document.createElement("li"); comment.innerHTML = this.renderComment(reply); if (reply.replies.length) { this.render(comment, reply.replies); } ul.appendChild(comment); } container.appendChild(ul); } renderComment(comment) { return `
${comment.content}

${ comment.boosts ? `${icons.reblog} ${comment.boosts}` : "" } ${ comment.likes ? `${icons.favourite} ${comment.likes}` : "" }

`; } } function formatEmojis(html, emojis) { emojis.forEach(({ shortcode, static_url, url }) => { html = html.replace( `:${shortcode}:`, ` :${shortcode}: `, ); }); return html; } async function fetchJSON(url, options = {}) { const headers = new Headers(); if (options.headers) { for (const [key, value] of Object.entries(options.headers)) { headers.set(key, value); } } if (typeof caches === "undefined") { return await (await fetch(url), { headers }).json(); } const cache = await caches.open("mastodon-comments"); let cached = await cache.match(url); if (cached && options.ttl) { const cacheTime = new Date(cached.headers.get("x-cached-at")); const diff = Date.now() - cacheTime.getTime(); if (diff <= options.ttl * 1000) { return await cached.json(); } } try { const response = await fetch(url, { headers }); const body = await response.json(); cached = new Response(JSON.stringify(body)); cached.headers.set("x-cached-at", new Date()); cached.headers.set("content-type", "application/json; charset=utf-8"); await cache.put(url, cached); return body; } catch { if (cached) { return await cached.json(); } } } function dataFromMastodon(data, author, source) { const comments = new Map(); // Transform data to a more usable format for (const comment of data.descendants) { if (comment.visibility !== "public") { continue; } const { account } = comment; const handler = `@${account.username}@${new URL(account.url).hostname}`; comments.set(comment.id, { id: comment.id, isMine: author === handler, source, url: comment.url, parent: comment.in_reply_to_id, createdAt: new Date(comment.created_at), content: formatEmojis(comment.content, comment.emojis), author: { name: formatEmojis(account.display_name, account.emojis), handler, url: account.url, avatar: account.avatar_static, alt: account.display_name, }, boosts: comment.reblogs_count, likes: comment.favourites_count, replies: [], }); } // Group comments by parent for (const comment of comments.values()) { if (comment.parent && comments.has(comment.parent)) { comments.get(comment.parent).replies.push(comment); } } return Array.from(comments.values()); } function dataFromBluesky(data) { const { thread } = data; return blueskyComments( thread.post.author.did, thread.post.cid, thread.replies, ); } function blueskyComments(author, parent, comments) { return comments.map((reply) => { const { post, replies } = reply; const rkey = post.uri.split("/").pop(); return { id: post.cid, isMine: post.author.did === author, source: "bluesky", url: `https://bsky.app/profile/${post.author.handle}/post/${rkey}`, parent, createdAt: new Date(post.record.createdAt), content: post.record.text, author: { name: post.author.displayName, handler: post.author.handle, url: `https://bsky.app/profile/${post.author.handle}`, avatar: post.author.avatar, alt: post.author.displayName, }, boosts: post.repostCount, likes: post.likeCount, replies: blueskyComments(author, post.cid, replies || []), }; }); }