Move some MastodonComments logic to Alpine

This commit is contained in:
Bad Manners 2024-09-25 18:19:08 -03:00
parent 328e84ccc7
commit d85522e4e6
Signed by: badmanners
GPG key ID: 8C88292CCB075609
2 changed files with 185 additions and 199 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "gallery.badmanners.xyz", "name": "gallery.badmanners.xyz",
"type": "module", "type": "module",
"version": "1.10.1", "version": "1.10.2",
"scripts": { "scripts": {
"postinstall": "astro sync", "postinstall": "astro sync",
"dev": "astro dev", "dev": "astro dev",

View file

@ -18,55 +18,65 @@ const { link, instance, user, postId, blacklistedComments } = Astro.props;
id="comments-section" id="comments-section"
class="px-2 font-serif" class="px-2 font-serif"
aria-describedby="title-comments-section" aria-describedby="title-comments-section"
data-link={link} x-data={`{ link: ${JSON.stringify(link)}, instance: ${JSON.stringify(instance)}, user: ${JSON.stringify(user)}, postId: ${JSON.stringify(postId)}, blacklistedComments: ${JSON.stringify(blacklistedComments ?? [])}, comments: null, commentsLoading: false, commentsError: null }`}
data-instance={instance} @mastodon-comments.window="comments = $event.detail.comments; commentsLoading = false"
data-user={user} @mastodon-comments-error.window="commentsError = $event.detail.error"
data-post-id={postId}
data-blacklisted={(blacklistedComments ?? []).join(",")}
> >
<h2 id="title-comments-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> <h2 id="title-comments-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
Comments Comments
</h2> </h2>
<p id="comments-description" class="my-1 text-stone-800 dark:text-stone-100"> <p id="comments-description" class="my-1 text-stone-800 dark:text-stone-100">
<span data-noscript <span x-show="false"
><a class="u-syndication text-link underline" href={link} target="_blank">View comments on Mastodon</a>.</span ><a class="u-syndication text-link underline" href={link} target="_blank">View comments on Mastodon</a>.</span
> >
<span hidden data-no-comments <template x-if="commentsError === null && comments !== null && comments.length == 0">
>No comments yet. <a class="text-link underline" href={link} target="_blank" <span
>Be the first to join the conversation on Mastodon</a >No comments yet. <a class="text-link underline" href={link} target="_blank"
>.</span >Be the first to join the conversation on Mastodon</a
> >.</span
<span hidden data-comments
>Join the conversation <a class="text-link underline" href={link} target="_blank">by replying on Mastodon</a
>.</span
>
<span hidden data-error>Unable to load comments. Please try again later.</span>
</p>
<button
hidden
class="group mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 text-stone-800 disabled:bg-bm-400 dark:bg-green-800 dark:text-stone-100 dark:disabled:bg-green-900"
id="load-comments-button"
>
<span class="block hover:underline group-focus:underline group-disabled:hidden">Click to load comments</span>
<span class="hidden group-disabled:block">
<svg
style={{ width: "1.25rem", height: "1.25rem", display: "inline" }}
class="-mt-1 mr-1 animate-spin"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
> >
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> </template>
<path <template x-if="commentsError === null && comments !== null && comments.length > 0"
class="opacity-100" ><span>
fill="currentColor" Join the conversation <a class="text-link underline" href={link} target="_blank">by replying on Mastodon</a
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" >.</span
></path> ></template
</svg> >
Loading... <template x-if="commentsError">
</span> <span>Unable to load comments. Please try again later.</span>
</button> </template>
<div id="comments" hidden></div> </p>
<template x-if="comments === null && !commentsError">
<button
class="group mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 text-stone-800 disabled:bg-bm-400 dark:bg-green-800 dark:text-stone-100 dark:disabled:bg-green-900"
id="load-comments-button"
@click="if (!$el.disabled) { $el.disabled = true; commentsLoading = true; $dispatch('retrieveComments', { link, instance, user, postId, blacklistedComments })}"
>
<span class="block hover:underline group-focus:underline group-disabled:hidden">Click to load comments</span>
<span class="hidden group-disabled:block">
<svg
style={{ width: "1.25rem", height: "1.25rem", display: "inline" }}
class="-mt-1 mr-1 animate-spin"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-100"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Loading...
</span>
</button>
</template>
<div id="comments" x-show="comments !== null && comments.length > 0">
<template x-for="comment in comments">
<div x-html="comment"></div>
</template>
</div>
</section> </section>
<template id="template-comment-emoji"> <template id="template-comment-emoji">
@ -111,14 +121,14 @@ const { link, instance, user, postId, blacklistedComments } = Astro.props;
> >
</div> </div>
<div class="ml-1 flex flex-row pb-2 pt-1"> <div class="ml-1 flex flex-row pb-2 pt-1">
<div class="flex" aria-label="Favorites"> <span aria-label="Favorites">
<span data-favorites></span> <span data-favorites></span>
<IconStar width="1.25rem" height="1.25rem" class="ml-2" /> <IconStar width="1.25rem" height="1.25rem" class="ml-1 inline align-text-bottom" />
</div> </span>
<div class="ml-4 flex" aria-label="Reblogs"> <span aria-label="Reblogs" class="ml-6">
<span data-reblogs></span> <span data-reblogs></span>
<IconRetweet width="1.25rem" height="1.25rem" class="ml-2" /> <IconRetweet width="1.25rem" height="1.25rem" class="ml-1 inline align-text-bottom" />
</div> </span>
</div> </div>
<div data-comment-thread class="-mb-2" aria-hidden="true" aria-label="Replies"></div> <div data-comment-thread class="-mb-2" aria-hidden="true" aria-label="Replies"></div>
</div> </div>
@ -165,166 +175,142 @@ const { link, instance, user, postId, blacklistedComments } = Astro.props;
descendants: Status[]; descendants: Status[];
} }
async function renderComments(section: Element, post: MastodonPost, blacklistedComments: Set<string>) { type RetrieveCommentsEvent = CustomEvent<{
const commentsDescription = section.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!; link: string;
const loadCommentsButton = section.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!; instance: string;
try { user: string;
const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`); postId: string;
if (!response.ok) { blacklistedComments: string[];
throw new Error(`Received error status ${response.status} - ${response.statusText}!`); }>;
}
const data: StatusContext = await response.json();
const emojiTemplate = document.querySelector<HTMLElementTagNameMap["template"]>( if (document.querySelector("#comments-section")) {
"template#template-comment-emoji", async function renderComments(post: MastodonPost, blacklistedComments: Set<string>) {
)!; try {
const emojiMap: Record<string, string> = {}; const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`);
const replaceEmojis = (text: string, emojis: CustomEmoji[]) => if (!response.ok) {
emojis.reduce((acc, emoji) => { throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
let emojiHTML = emojiMap[emoji.url]; }
if (!emojiHTML) { const data: StatusContext = await response.json();
const emojiPicture = emojiTemplate.content.cloneNode(true) as DocumentFragment;
const emojiStatic = emojiPicture.querySelector("source")!; const emojiTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
emojiStatic.srcset = emoji.static_url; "template#template-comment-emoji",
const emojiImg = emojiPicture.querySelector("img")!; )!;
emojiImg.src = emoji.url; const emojiMap: Record<string, string> = {};
emojiImg.alt = `:${emoji.shortcode}: emoji`; const replaceEmojis = (text: string, emojis: CustomEmoji[]) =>
emojiHTML = emojiPicture.firstElementChild!.outerHTML; emojis.reduce((acc, emoji) => {
emojiMap[emoji.url] = emojiHTML; let emojiHTML = emojiMap[emoji.url];
if (!emojiHTML) {
const emojiPicture = emojiTemplate.content.cloneNode(true) as DocumentFragment;
const emojiStatic = emojiPicture.querySelector("source")!;
emojiStatic.srcset = emoji.static_url;
const emojiImg = emojiPicture.querySelector("img")!;
emojiImg.src = emoji.url;
emojiImg.alt = `:${emoji.shortcode}: emoji`;
emojiHTML = emojiPicture.firstElementChild!.outerHTML;
emojiMap[emoji.url] = emojiHTML;
}
return acc.replaceAll(`:${emoji.shortcode}:`, emojiHTML);
}, text);
const commentsList: DocumentFragment[] = [];
const commentMap: Record<string, number> = {};
const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
"template#template-comment-box",
)!;
data.descendants.forEach((comment) => {
if (blacklistedComments.has(comment.id)) {
return;
} }
return acc.replaceAll(`:${emoji.shortcode}:`, emojiHTML); if (blacklistedComments.has(comment.in_reply_to_id)) {
}, text); blacklistedComments.add(comment.id);
const commentsList: DocumentFragment[] = []; return;
const commentMap: Record<string, number> = {}; }
const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>( const commentBox = commentTemplate.content.cloneNode(true) as DocumentFragment;
"template#template-comment-box", commentBox.firstElementChild!.id = `comment-${comment.id}`;
)!;
data.descendants.forEach((comment) => { const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
if (blacklistedComments.has(comment.id)) { commentBoxAuthor.href = comment.account.url;
return; commentBoxAuthor.title = comment.account.acct;
} commentBoxAuthor.setAttribute("aria-label", comment.account.acct);
if (blacklistedComments.has(comment.in_reply_to_id)) { const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
blacklistedComments.add(comment.id); avatar.src = comment.account.avatar;
return; avatar.alt = `Avatar of @${comment.account.acct}`;
} const avatarStatic =
const commentBox = commentTemplate.content.cloneNode(true) as DocumentFragment; commentBoxAuthor.querySelector<HTMLElementTagNameMap["source"]>("source[data-avatar-static]")!;
commentBox.firstElementChild!.id = `comment-${comment.id}`; avatarStatic.srcset = comment.account.avatar_static;
const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
displayName.insertAdjacentHTML(
"afterbegin",
replaceEmojis(comment.account.display_name, comment.account.emojis),
);
const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!; const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!;
commentBoxAuthor.href = comment.account.url; commentBoxPostLink.href = comment.url;
commentBoxAuthor.title = comment.account.acct; const publishDate =
commentBoxAuthor.setAttribute("aria-label", comment.account.acct); commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-published-date]")!;
const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!; publishDate.dateTime = comment.created_at;
avatar.src = comment.account.avatar; publishDate.insertAdjacentText(
avatar.alt = `Avatar of @${comment.account.acct}`; "afterbegin",
const avatarStatic = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
commentBoxAuthor.querySelector<HTMLElementTagNameMap["source"]>("source[data-avatar-static]")!; month: "short",
avatarStatic.srcset = comment.account.avatar_static; day: "numeric",
const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!; year: "numeric",
displayName.insertAdjacentHTML( hour: "2-digit",
"afterbegin", minute: "2-digit",
replaceEmojis(comment.account.display_name, comment.account.emojis), }),
); );
const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!; if (comment.edited_at) {
commentBoxPostLink.href = comment.url; const edited = commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-edited-date]")!;
const publishDate = edited.dateTime = comment.edited_at;
commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-published-date]")!; edited.title = comment.edited_at;
publishDate.dateTime = comment.created_at; edited.classList.remove("hidden");
publishDate.insertAdjacentText( edited.classList.add("dt-updated");
"afterbegin", edited.hidden = false;
new Date(Date.parse(comment.created_at)).toLocaleString("en-US", { }
month: "short",
day: "numeric", const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!;
year: "numeric", commentBoxContent.insertAdjacentHTML("afterbegin", replaceEmojis(comment.content, comment.emojis));
hour: "2-digit",
minute: "2-digit", const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
commentBoxFavorites.insertAdjacentText("afterbegin", comment.favourites_count.toString());
const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
commentBoxReblogs.insertAdjacentText("afterbegin", comment.reblogs_count.toString());
if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) {
commentMap[comment.id] = commentsList.length;
commentsList.push(commentBox);
} else if (comment.in_reply_to_id in commentMap) {
const commentsIndex = commentMap[comment.in_reply_to_id]!;
commentMap[comment.id] = commentsIndex;
const parentThreadDiv =
commentsList[commentsIndex]!.querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
parentThreadDiv.setAttribute("aria-hidden", "false");
parentThreadDiv.appendChild(commentBox);
}
});
window.dispatchEvent(
new CustomEvent("mastodon-comments", {
detail: {
comments: commentsList.map((fragment) => {
const div = document.createElement("div");
div.appendChild(fragment);
return div.innerHTML;
}),
},
}), }),
); );
console.log(commentsList);
if (comment.edited_at) { } catch (e) {
const edited = commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-edited-date]")!; console.error("Fetch Mastodon comments error", e);
edited.dateTime = comment.edited_at; window.dispatchEvent(new CustomEvent("mastodon-comments-error", { detail: { error: e } }));
edited.title = comment.edited_at;
edited.classList.remove("hidden");
edited.classList.add("dt-updated");
edited.hidden = false;
}
const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!;
commentBoxContent.insertAdjacentHTML("afterbegin", replaceEmojis(comment.content, comment.emojis));
const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
commentBoxFavorites.insertAdjacentText("afterbegin", comment.favourites_count.toString());
const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
commentBoxReblogs.insertAdjacentText("afterbegin", comment.reblogs_count.toString());
if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) {
commentMap[comment.id] = commentsList.length;
commentsList.push(commentBox);
} else if (commentMap[comment.in_reply_to_id]) {
const commentsIndex = commentMap[comment.in_reply_to_id]!;
commentMap[comment.id] = commentsIndex;
const parentThreadDiv =
commentsList[commentsIndex]!.querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
parentThreadDiv.setAttribute("aria-hidden", "false");
parentThreadDiv.appendChild(commentBox);
}
});
if (commentsList.length) {
const fragment = document.createDocumentFragment();
commentsList.forEach((comment) => fragment.appendChild(comment));
commentsDescription.querySelector<HTMLElementTagNameMap["span"]>("span[data-comments]")!.hidden = false;
const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
commentsDiv.appendChild(fragment);
commentsDiv.hidden = false;
} else {
commentsDescription.querySelector<HTMLElementTagNameMap["span"]>("span[data-no-comments]")!.hidden = false;
} }
} catch (e) {
console.error("Fetch Mastodon comments error", e);
commentsDescription.querySelector<HTMLElementTagNameMap["span"]>("span[data-error]")!.hidden = false;
} finally {
loadCommentsButton.hidden = true;
loadCommentsButton.blur();
commentsDescription.hidden = false;
} }
}
function initCommentSection() { document.addEventListener("retrieveComments", (e) => {
const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comments-section"); const { link, instance, user, postId, blacklistedComments } = (e as RetrieveCommentsEvent).detail;
if (!commentSection) { renderComments({ link, instance, user, postId }, new Set(blacklistedComments));
return; });
}
const post = {
link: commentSection.dataset.link,
instance: commentSection.dataset.instance,
user: commentSection.dataset.user,
postId: commentSection.dataset.postId,
};
if (!post.link || !post.instance || !post.user || !post.postId) {
return;
}
const blacklisted = commentSection.dataset.blacklisted;
const blacklistedComments = new Set(blacklisted ? blacklisted.split(",") : undefined);
const loadCommentsButton =
commentSection.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!;
loadCommentsButton.addEventListener(
"click",
(e) => {
e.preventDefault();
loadCommentsButton.disabled = true;
renderComments(commentSection, post as MastodonPost, blacklistedComments);
},
{ once: true },
);
const commentsDescription = commentSection.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!;
commentsDescription.hidden = true;
commentsDescription.querySelector<HTMLElementTagNameMap["span"]>("span[data-noscript]")!.hidden = true;
loadCommentsButton.hidden = false;
} }
initCommentSection();
</script> </script>