diff --git a/package.json b/package.json index 539ed0d..f6463b6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gallery.badmanners.xyz", "type": "module", - "version": "1.10.1", + "version": "1.10.2", "scripts": { "postinstall": "astro sync", "dev": "astro dev", diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro index 454101a..9250ac3 100644 --- a/src/components/MastodonComments.astro +++ b/src/components/MastodonComments.astro @@ -18,55 +18,65 @@ const { link, instance, user, postId, blacklistedComments } = Astro.props; id="comments-section" class="px-2 font-serif" aria-describedby="title-comments-section" - data-link={link} - data-instance={instance} - data-user={user} - data-post-id={postId} - data-blacklisted={(blacklistedComments ?? []).join(",")} + 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 }`} + @mastodon-comments.window="comments = $event.detail.comments; commentsLoading = false" + @mastodon-comments-error.window="commentsError = $event.detail.error" > <h2 id="title-comments-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> Comments </h2> <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 > - <span hidden data-no-comments - >No comments yet. <a class="text-link underline" href={link} target="_blank" - >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" + <template x-if="commentsError === null && comments !== null && comments.length == 0"> + <span + >No comments yet. <a class="text-link underline" href={link} target="_blank" + >Be the first to join the conversation on Mastodon</a + >.</span > - <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> - <div id="comments" hidden></div> + </template> + <template x-if="commentsError === null && comments !== null && comments.length > 0" + ><span> + Join the conversation <a class="text-link underline" href={link} target="_blank">by replying on Mastodon</a + >.</span + ></template + > + <template x-if="commentsError"> + <span>Unable to load comments. Please try again later.</span> + </template> + </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> <template id="template-comment-emoji"> @@ -111,14 +121,14 @@ const { link, instance, user, postId, blacklistedComments } = Astro.props; > </div> <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> - <IconStar width="1.25rem" height="1.25rem" class="ml-2" /> - </div> - <div class="ml-4 flex" aria-label="Reblogs"> + <IconStar width="1.25rem" height="1.25rem" class="ml-1 inline align-text-bottom" /> + </span> + <span aria-label="Reblogs" class="ml-6"> <span data-reblogs></span> - <IconRetweet width="1.25rem" height="1.25rem" class="ml-2" /> - </div> + <IconRetweet width="1.25rem" height="1.25rem" class="ml-1 inline align-text-bottom" /> + </span> </div> <div data-comment-thread class="-mb-2" aria-hidden="true" aria-label="Replies"></div> </div> @@ -165,166 +175,142 @@ const { link, instance, user, postId, blacklistedComments } = Astro.props; descendants: Status[]; } - async function renderComments(section: Element, post: MastodonPost, blacklistedComments: Set<string>) { - const commentsDescription = section.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!; - const loadCommentsButton = section.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!; - try { - const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`); - if (!response.ok) { - throw new Error(`Received error status ${response.status} - ${response.statusText}!`); - } - const data: StatusContext = await response.json(); + type RetrieveCommentsEvent = CustomEvent<{ + link: string; + instance: string; + user: string; + postId: string; + blacklistedComments: string[]; + }>; - const emojiTemplate = document.querySelector<HTMLElementTagNameMap["template"]>( - "template#template-comment-emoji", - )!; - const emojiMap: Record<string, string> = {}; - const replaceEmojis = (text: string, emojis: CustomEmoji[]) => - emojis.reduce((acc, emoji) => { - 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; + if (document.querySelector("#comments-section")) { + async function renderComments(post: MastodonPost, blacklistedComments: Set<string>) { + try { + const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`); + if (!response.ok) { + throw new Error(`Received error status ${response.status} - ${response.statusText}!`); + } + const data: StatusContext = await response.json(); + + const emojiTemplate = document.querySelector<HTMLElementTagNameMap["template"]>( + "template#template-comment-emoji", + )!; + const emojiMap: Record<string, string> = {}; + const replaceEmojis = (text: string, emojis: CustomEmoji[]) => + emojis.reduce((acc, emoji) => { + 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); - }, text); - const commentsList: DocumentFragment[] = []; - const commentMap: Record<string, number> = {}; - const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>( - "template#template-comment-box", - )!; + if (blacklistedComments.has(comment.in_reply_to_id)) { + blacklistedComments.add(comment.id); + return; + } + const commentBox = commentTemplate.content.cloneNode(true) as DocumentFragment; + commentBox.firstElementChild!.id = `comment-${comment.id}`; - data.descendants.forEach((comment) => { - if (blacklistedComments.has(comment.id)) { - return; - } - if (blacklistedComments.has(comment.in_reply_to_id)) { - blacklistedComments.add(comment.id); - return; - } - const commentBox = commentTemplate.content.cloneNode(true) as DocumentFragment; - commentBox.firstElementChild!.id = `comment-${comment.id}`; + const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!; + commentBoxAuthor.href = comment.account.url; + commentBoxAuthor.title = comment.account.acct; + commentBoxAuthor.setAttribute("aria-label", comment.account.acct); + const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!; + avatar.src = comment.account.avatar; + avatar.alt = `Avatar of @${comment.account.acct}`; + const avatarStatic = + commentBoxAuthor.querySelector<HTMLElementTagNameMap["source"]>("source[data-avatar-static]")!; + 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]")!; - commentBoxAuthor.href = comment.account.url; - commentBoxAuthor.title = comment.account.acct; - commentBoxAuthor.setAttribute("aria-label", comment.account.acct); - const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!; - avatar.src = comment.account.avatar; - avatar.alt = `Avatar of @${comment.account.acct}`; - const avatarStatic = - commentBoxAuthor.querySelector<HTMLElementTagNameMap["source"]>("source[data-avatar-static]")!; - 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 commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!; + commentBoxPostLink.href = comment.url; + const publishDate = + commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-published-date]")!; + publishDate.dateTime = comment.created_at; + publishDate.insertAdjacentText( + "afterbegin", + new Date(Date.parse(comment.created_at)).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }), + ); - const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!; - commentBoxPostLink.href = comment.url; - const publishDate = - commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-published-date]")!; - publishDate.dateTime = comment.created_at; - publishDate.insertAdjacentText( - "afterbegin", - new Date(Date.parse(comment.created_at)).toLocaleString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", + if (comment.edited_at) { + const edited = commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-edited-date]")!; + edited.dateTime = comment.edited_at; + 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 (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; + }), + }, }), ); - - if (comment.edited_at) { - const edited = commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-edited-date]")!; - edited.dateTime = comment.edited_at; - 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; + console.log(commentsList); + } catch (e) { + console.error("Fetch Mastodon comments error", e); + window.dispatchEvent(new CustomEvent("mastodon-comments-error", { detail: { error: e } })); } - } 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() { - const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comments-section"); - if (!commentSection) { - 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; + document.addEventListener("retrieveComments", (e) => { + const { link, instance, user, postId, blacklistedComments } = (e as RetrieveCommentsEvent).detail; + renderComments({ link, instance, user, postId }, new Set(blacklistedComments)); + }); } - - initCommentSection(); </script>