// © https://phosphoricons.com/
export const icons = {
reblog:
``,
favourite:
``,
author:
``,
// @ https://simpleicons.org/
mastodon:
``,
pleroma:
``,
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 `
`;
}
}
function formatEmojis(html, emojis) {
emojis.forEach(({ shortcode, static_url, url }) => {
html = html.replace(
`:${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 || []),
};
});
}
${ comment.boosts ? `${icons.reblog} ${comment.boosts}` : "" } ${ comment.likes ? `${icons.favourite} ${comment.likes}` : "" }