- Improved schema validation - Move username parsing and other validators to schema types - Fix astro check command - Add JSON/YAML schema validation for data collections - Update licenses - Remove deployment script in favor of rsync - Prevent unsanitized input in export-story script - Change "eng" language to "en", per BCP47 - Clean up i18n keys and add aria attributes - Improve MastodonComments behavior on no-JS browsers
255 lines
10 KiB
Text
255 lines
10 KiB
Text
---
|
|
import type { Lang } from "../content/config";
|
|
|
|
type Props = {
|
|
lang: Lang;
|
|
link: string;
|
|
instance: string;
|
|
user: string;
|
|
postId: string;
|
|
};
|
|
|
|
const { link, instance, user, postId } = Astro.props;
|
|
---
|
|
|
|
<section
|
|
id="comment-section"
|
|
class="px-2 font-serif"
|
|
aria-describedby="title-comment-section"
|
|
data-link={link}
|
|
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">
|
|
<p class="my-1">
|
|
<a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<template id="template-button">
|
|
<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"
|
|
>
|
|
<span>Click to load comments</span>
|
|
</button>
|
|
</template>
|
|
|
|
<template id="template-button-loading">
|
|
<svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden>
|
|
<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" target="_blank">
|
|
<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"
|
|
target="_blank"
|
|
>
|
|
<span class="mr-1" data-publish-date aria-label="Publish date"></span>
|
|
</a>
|
|
</div>
|
|
<div data-content class="prose-a:text-link prose-story prose 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" aria-hidden></div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
interface Post {
|
|
link: string;
|
|
instance: string;
|
|
user: string;
|
|
postId: string;
|
|
}
|
|
|
|
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[];
|
|
}
|
|
|
|
interface ApiResponse {
|
|
ancestors: Comment[];
|
|
descendants: Comment[];
|
|
}
|
|
|
|
(function () {
|
|
function replaceEmojis(text: string, emojis: Emoji[]) {
|
|
return emojis.reduce(
|
|
(acc, emoji) =>
|
|
acc.replaceAll(
|
|
`:${emoji.shortcode}:`,
|
|
`<img class="inline mx-[1px] w-5" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
|
|
),
|
|
text,
|
|
);
|
|
}
|
|
|
|
async function renderComments(section: Element, post: Post) {
|
|
const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
|
|
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: ApiResponse = await response.json();
|
|
|
|
const commentsList: HTMLElement[] = [];
|
|
const commentMap: Record<string, number> = {};
|
|
const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
|
|
"template#template-comment-box",
|
|
)!;
|
|
data.descendants.forEach((comment) => {
|
|
const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
|
|
|
|
const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
|
|
commentBoxAuthor.href = comment.account.url;
|
|
const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
|
|
avatar.src = comment.account.avatar;
|
|
avatar.alt = `Profile picture of ${comment.account.username}`;
|
|
const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
|
|
displayName.innerHTML = 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["span"]>("span[data-publish-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<HTMLElementTagNameMap["div"]>("div[data-content]")!;
|
|
commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis);
|
|
|
|
const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
|
|
commentBoxFavorites.innerText = comment.favourites_count.toString();
|
|
|
|
const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
|
|
commentBoxReblogs.innerText = 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 {
|
|
const commentsIndex = commentMap[comment.in_reply_to_id];
|
|
commentMap[comment.id] = commentsIndex;
|
|
const parentThreadDiv =
|
|
commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
|
|
parentThreadDiv.removeAttribute("aria-hidden");
|
|
parentThreadDiv.setAttribute("aria-label", "Replies");
|
|
parentThreadDiv.appendChild(commentBox);
|
|
}
|
|
});
|
|
if (commentsList.length === 0) {
|
|
commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="${post.link}" target="_blank">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="${post.link}" target="_blank">by replying on Mastodon</a>.</p>`;
|
|
commentsDiv.append(...commentsList);
|
|
}
|
|
} catch (e) {
|
|
commentsDiv.innerHTML = `<p class="my-1">Unable to load comments. Please try again later.</p>`;
|
|
console.error("Fetch Mastodon comments error", e);
|
|
}
|
|
}
|
|
|
|
function initCommentSection() {
|
|
const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comment-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 loadCommentsButton = document
|
|
.querySelector<HTMLElementTagNameMap["template"]>("template#template-button")!
|
|
.content.cloneNode(true) as HTMLButtonElement;
|
|
commentSection.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!.replaceChildren(loadCommentsButton);
|
|
loadCommentsButton.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
loadCommentsButton.setAttribute("disabled", "true");
|
|
loadCommentsButton.replaceChildren(
|
|
document
|
|
.querySelector<HTMLElementTagNameMap["template"]>("template#template-button-loading")!
|
|
.content.cloneNode(true),
|
|
);
|
|
renderComments(commentSection, post as Post);
|
|
});
|
|
}
|
|
|
|
initCommentSection();
|
|
})();
|
|
</script>
|