From d85522e4e64038b37e6384643500844ba18012ec Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Wed, 25 Sep 2024 18:19:08 -0300
Subject: [PATCH] Move some MastodonComments logic to Alpine

---
 package.json                          |   2 +-
 src/components/MastodonComments.astro | 382 +++++++++++++-------------
 2 files changed, 185 insertions(+), 199 deletions(-)

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>