--- type Props = { instance?: string; user?: string; postId?: string; }; const { instance, user, postId } = Astro.props; --- <section id="comment-section" class="hidden px-2 font-serif" aria-describedby="title-comment-section" data-instance={instance || ""} data-user={user || ""} data-post-id={postId || ""} > <h2 id="title-comment-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> Comments </h2> <div class="text-stone-800 dark:text-stone-100" id="comments"> <button class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900" id="load-comments-button" data-load-comments > <span>Click to load comments</span> </button> </div> </section> <template id="template-comments-loading"> <svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24"> <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> <span>Loading...</span> </template> <template id="template-comment-box"> <div class="my-2 rounded-md border-2 border-stone-400 bg-stone-200 p-2 dark:border-stone-600 dark:bg-stone-800"> <div class="ml-1"> <a data-author class="text-link flex items-center text-lg hover:underline focus:underline"> <img data-avatar class="mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" /> <span data-display-name></span> </a> <a data-post-link class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"> <span class="mr-1" data-publish-date></span> </a> </div> <div data-content class="prose-a:text-link prose prose-story my-1 dark:prose-invert prose-img:my-0"></div> <div class="ml-1 flex flex-row pb-2 pt-1"> <div class="flex" aria-label="Favorites"> <span data-favorites></span> <svg class="ml-2 w-5 fill-current" viewBox="0 0 576 512" aria-hidden> <path d="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z" ></path> </svg> </div> <div class="ml-4 flex" aria-label="Reblogs"> <span data-reblogs></span> <svg class="ml-2 w-5 fill-current" viewBox="0 0 512 512" aria-hidden> <path d="M0 224c0 17.7 14.3 32 32 32s32-14.3 32-32c0-53 43-96 96-96H320v32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S320 19.1 320 32V64H160C71.6 64 0 135.6 0 224zm512 64c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 53-43 96-96 96H192V352c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6V448H352c88.4 0 160-71.6 160-160z" ></path> </svg> </div> </div> <div data-comment-thread class="-mb-2"></div> </div> </template> <script> interface Emoji { shortcode: string; url: string; static_url: string; } interface Comment { id: string; in_reply_to_id: string; url: string; favourites_count: number; reblogs_count: number; created_at: string; edited_at: string | null; account: { username: string; acct: string; display_name: string; url: string; avatar: string; avatar_static: string; emojis: Emoji[]; }; content: string; emojis: Emoji[]; } (function () { const replaceEmojis = (text: string, emojis: Emoji[], imgClass: string) => emojis.reduce( (acc, emoji) => acc.replaceAll( `:${emoji.shortcode}:`, `<img class="${imgClass}" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`, ), text, ); const commentSection = document.querySelector<Element>("#comment-section")!; const instance = commentSection.getAttribute("data-instance"); const user = commentSection.getAttribute("data-user"); const postId = commentSection.getAttribute("data-post-id"); if (instance && user && postId) { commentSection.classList.remove("hidden"); commentSection.querySelector<HTMLButtonElement>("button[data-load-comments]")!.addEventListener("click", (e) => { e.preventDefault(); const loadCommentsButton = e.target as HTMLButtonElement; loadCommentsButton.setAttribute("disabled", "true"); loadCommentsButton.replaceChildren( (document.getElementById("template-comments-loading") as HTMLTemplateElement).content.cloneNode(true), ); const renderComments = async () => { try { if (!instance || !user || !postId) { throw new Error( `Cannot fetch comments without all fields (instance=${instance}, user=${user}, post-id=${postId})`, ); } const response = await fetch(`https://${instance}/api/v1/statuses/${postId}/context`); if (!response.ok) { throw new Error(`Received error status ${response.status} - ${response.statusText}!`); } const data: { ancestors: Comment[]; descendants: Comment[] } = await response.json(); // console.log(data); const commentsList: HTMLElement[] = []; const commentMap: Record<string, number> = {}; const commentTemplate = document.getElementById("template-comment-box") as HTMLTemplateElement; data.descendants.forEach((comment) => { const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement; const commentBoxAuthor = commentBox.querySelector<HTMLAnchorElement>("[data-author]")!; commentBoxAuthor.href = comment.account.url; commentBoxAuthor.target = "_blank"; const avatar = commentBoxAuthor.querySelector<HTMLImageElement>("[data-avatar]")!; avatar.src = comment.account.avatar; avatar.alt = `Profile picture of ${comment.account.username}`; const displayName = commentBoxAuthor.querySelector<HTMLSpanElement>("[data-display-name]")!; displayName.innerHTML = replaceEmojis( comment.account.display_name, comment.account.emojis, "inline mx-[1px] w-5", ); const commentBoxPostLink = commentBox.querySelector<HTMLAnchorElement>("[data-post-link]")!; commentBoxPostLink.href = comment.url; commentBoxPostLink.target = "_blank"; const publishDate = commentBoxPostLink.querySelector<HTMLSpanElement>("[data-publish-date]")!; // TO-DO Pretty format date publishDate.innerText = 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 = document.createElement("span"); edited.className = "italic"; edited.innerText = "(edited)"; commentBoxPostLink.appendChild(edited); } const commentBoxContent = commentBox.querySelector<HTMLDivElement>("[data-content]")!; commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis, "inline mx-[1px] w-5"); const commentBoxFavorites = commentBox.querySelector<HTMLSpanElement>("[data-favorites]")!; commentBoxFavorites.innerText = comment.favourites_count.toString(); const commentBoxReblogs = commentBox.querySelector<HTMLSpanElement>("[data-reblogs]")!; commentBoxReblogs.innerText = comment.reblogs_count.toString(); if (comment.in_reply_to_id === postId || !(comment.in_reply_to_id in commentMap)) { commentMap[comment.id] = commentsList.length; commentsList.push(commentBox); } else { const commentsIndex = commentMap[comment.in_reply_to_id]; commentMap[comment.id] = commentsIndex; commentsList[commentsIndex] .querySelector<HTMLDivElement>("[data-comment-thread]")! .appendChild(commentBox); } }); const commentsDiv = commentSection.querySelector<HTMLDivElement>("#comments")!; if (commentsList.length === 0) { commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">Be the first to join the conversation on Mastodon</a>.</p>`; } else { commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">by replying on Mastodon</a>.</p>`; commentsDiv.append(...commentsList); } } catch (e) { loadCommentsButton.innerHTML = `<span>Unable to load comments.</span>`; console.error("Fetch Mastodon comments error", e); } }; renderComments(); }); } })(); </script>