feat: add client-loaded hatsu comments

This commit is contained in:
草师傅 2025-07-20 20:08:11 +08:00
parent d9f04f1a88
commit 99a981209c
Signed by: gb
GPG key ID: 43330A030E2D6478
2 changed files with 496 additions and 0 deletions

View file

@ -1,6 +1,7 @@
--- ---
import {siteConfig} from "../config"; import {siteConfig} from "../config";
import FediverseComments from "./helper/comments/Fediverse.astro"; import FediverseComments from "./helper/comments/Fediverse.astro";
import HatsuComments from "./helper/comments/Hatsu.astro";
const method = siteConfig.comments.type const method = siteConfig.comments.type
const ArtalkConfig = siteConfig.comments.artalk const ArtalkConfig = siteConfig.comments.artalk
@ -52,3 +53,4 @@ let { path='/' } = Astro.props;
<!-- if prerender === true is set then render from client --> <!-- if prerender === true is set then render from client -->
{(method === 'fediverse' && !FediverseConfig.renderOnServer ) && <FediverseComments path={path} /> } {(method === 'fediverse' && !FediverseConfig.renderOnServer ) && <FediverseComments path={path} /> }
{(method === 'fediverse' && FediverseConfig.renderOnServer ) && <FediverseComments server:defer path={path} ><p>Loading comments...</p></FediverseComments> } {(method === 'fediverse' && FediverseConfig.renderOnServer ) && <FediverseComments server:defer path={path} ><p>Loading comments...</p></FediverseComments> }
{method === 'hatsu' && <HatsuComments /> }

View file

@ -0,0 +1,494 @@
---
import {siteConfig} from "../../../config";
const hatsuHost = `https://${siteConfig.comments.hatsu.instanceDomain}`
const {origin, pathname} = new URL(Astro.url)
const url = new URL(pathname, origin).href
import { transform } from "ultrahtml";
import sanitize from "ultrahtml/transformers/sanitize";
---
<section id="comments" class="container">
<p>Comments by <a href="https://hatsu.cli.rs">Hatsu</a></p>
<p id="mastodon-comments-list"><button id="load-comment">Load Comments</button></p>
<div id="comments-wrapper" class="mastodon-comment">
<noscript><p>JavaScript is needed for loading comments, or you copy the post's URL to your clipboard and paste it into your Fediverse instance's search box., </p></noscript>
</div>
</section>
<style is:inline>
button#load-comment, button.addComment {
background-color: var(--accent-color);
color: var(--bg-color);
border-radius: 0.25rem;
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
border: none;
}
button.button {
background-color: var(--accent-color);
color: var(--bg-color);
border-radius: 0.25rem;
padding: 0.25rem;
font-size: 1rem;
cursor: pointer;
border: none;
}
section#comments #comments-wrapper {
margin:1.5em 0;
padding:0 5px
}
section#comments .comment {
display:grid;
column-gap:1rem;
grid-template-areas:"avatar name" "avatar time" "avatar post" "...... interactions";
grid-template-columns:min-content;
justify-items:start;
margin:0 auto 0 -1em;
padding:.5em;
}
section#comments .comment.comment-reply {
margin:0 auto 0 1em;
}
section#comments .comment .avatar-link {
grid-area:avatar;
height:4rem;
position:relative;
width:4rem;
}
section#comments .comment .avatar-link .avatar {
max-height: 100%;
max-width: 100%;
border-radius: 50%;
box-sizing: border-box;
}
section#comments .comment .avatar-link.op::after {
background-color: var(--terminal-green);
border-radius:50%;
bottom:-.25rem;
color: #fafafa;
content:"✓";
display:block;
font-size:1.25rem;
font-weight:700;
height:1.5rem;
line-height:1.5rem;
position:absolute;
right:-.25rem;
text-align:center;
width:1.5rem
}
section#comments .comment .author {
align-items:center;
display:flex;
font-weight:700;
gap:.5em;
grid-area:name
}
section#comments .comment .author .instance {
background-color: var(--terminal-green);
border-radius:9999px;
color: #fafafa;
font-size:smaller;
font-weight:400;
padding:.25em .75em
}
section#comments .comment .author .instance:hover {
opacity:.8;
text-decoration:none
}
section#comments .comment .author .instance.op {
background-color: var(--terminal-green);
color: #fafafa
}
section#comments .comment .author .instance.op::before {
content:"✓";
font-weight:700;
margin-inline-end:.25em;
margin-inline-start:-.25em
}
section#comments .comment time {
grid-area:time;
line-height:1.5rem
}
section#comments .comment main {
grid-area:post
}
section#comments .comment main p:first-child {
margin-top:.25em
}
section#comments .comment main p:last-child {
margin-bottom:0
}
section#comments .comment footer {
grid-area:interactions
}
section#comments .comment footer .faves {
color:inherit
}
section#comments .comment footer .faves:hover {
opacity:.8;
text-decoration:none
}
section#comments .comment footer .faves::before {
color:yellow;
content:"☆";
font-size:2rem;
margin-inline-end:.25em
}
section#comments .comment .emoji {
display:inline;
height:1.25em;
vertical-align:middle;
width:1.25em
}
section#comments .comment .invisible {
display:none
}
section#comments .comment .ellipsis::after {
content:"…"
}
/*comment dialog*/
section#comments #comment-dialog {
width: 30em;
padding: 1em;
border-radius: 5px;
background-color: var(--bg-color);
margin: auto;
}
section#comments #comment-dialog {
color: var(--text-color);
}
section#comments #comment-dialog #close {
position: absolute;
top: 0;
right: 0;
background: none;
color: inherit;
border: none;
padding: 0.5em;
font: inherit;
outline: inherit;
}
section#comments #comment-dialog .input-row {
display: flex;
}
section#comments #comment-dialog .input-row > input {
flex-grow: 1;
margin-right: 0.5em;
}
section#comments #comment-dialog .input-row > button {
flex-basis: 3em;
}
</style>
<script define:vars={{ hatsuHost, url }}>
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function emojify(input, emojis) {
let output = input;
emojis.forEach(emoji => {
let picture = document.createElement("picture");
let source = document.createElement("source");
source.setAttribute("srcset", escapeHtml(emoji.url));
source.setAttribute("media", "(prefers-reduced-motion: no-preference)");
let img = document.createElement("img");
img.className = "emoji";
img.setAttribute("src", escapeHtml(emoji.static_url));
img.setAttribute("alt", `:${emoji.shortcode}:`);
img.setAttribute("title", `:${emoji.shortcode}:`);
img.setAttribute("width", "20");
img.setAttribute("height", "20");
picture.appendChild(source);
picture.appendChild(img);
output = output.replace(`:${emoji.shortcode}:`, picture.outerHTML);
});
return output;
}
function loadComments() {
// get id (base64url encode)
// aHR0cHM6Ly9leGFtcGxlLmNvbS9mb28vYmFy
const id = btoa(url).replaceAll('+', '-').replaceAll('/', '_')
let commentsWrapper = document.getElementById("comments-wrapper");
document.getElementById("load-comment").innerHTML = "Loading";
fetch(new URL(`/api/v1/statuses/${id}/context`, hatsuHost))
.then(function (response) {
return response.json();
})
.then(function (data) {
let descendants = data['descendants'];
if (
descendants &&
Array.isArray(descendants) &&
descendants.length > 0
) {
commentsWrapper.innerHTML = "";
descendants.forEach(function (status) {
console.log(descendants)
if (status.account.display_name.length > 0) {
status.account.display_name = escapeHtml(status.account.display_name);
status.account.display_name = emojify(status.account.display_name, status.account.emojis);
} else {
status.account.display_name = status.account.username;
}
;
let instance = "";
if (!status.account.id.includes("o3o.ca")) {
instance = status.account.id.split("/")[2];
} else {
instance = "o3o.ca";
}
const isReply = status.in_reply_to_id !== id;
let op = false;
if (status.account.username == "grassblock") {
op = true;
}
status.content = emojify(status.content, status.emojis);
let avatarSource = document.createElement("source");
avatarSource.setAttribute("srcset", escapeHtml(status.account.avatar));
avatarSource.setAttribute("media", "(prefers-reduced-motion: no-preference)");
let avatarImg = document.createElement("img");
avatarImg.className = "avatar";
avatarImg.setAttribute("src", escapeHtml(status.account.avatar_static));
avatarImg.setAttribute("alt", `@${status.account.username}@${instance} avatar`);
let avatarPicture = document.createElement("picture");
avatarPicture.appendChild(avatarSource);
avatarPicture.appendChild(avatarImg);
let avatar = document.createElement("a");
avatar.className = "avatar-link";
avatar.setAttribute("href", status.account.url);
avatar.setAttribute("rel", "external nofollow");
avatar.setAttribute("title", `View profile at @${status.account.username}@${instance}`);
avatar.appendChild(avatarPicture);
let instanceBadge = document.createElement("a");
instanceBadge.className = "instance";
instanceBadge.setAttribute("href", status.account.url);
instanceBadge.setAttribute("title", `@${status.account.username}@${instance}`);
instanceBadge.setAttribute("rel", "external nofollow");
instanceBadge.textContent = instance;
let display = document.createElement("span");
display.className = "display";
display.setAttribute("itemprop", "author");
display.setAttribute("itemtype", "http://schema.org/Person");
display.innerHTML = status.account.display_name;
let header = document.createElement("header");
header.className = "author";
header.appendChild(display);
header.appendChild(instanceBadge);
let permalink = document.createElement("a");
permalink.setAttribute("href", status.url);
permalink.setAttribute("itemprop", "url");
permalink.setAttribute("title", `View comment at ${instance}`);
permalink.setAttribute("rel", "external nofollow");
permalink.textContent = new Date(status.created_at).toLocaleString('en-US', {
dateStyle: "long",
timeStyle: "short",
});
let timestamp = document.createElement("time");
timestamp.setAttribute("datetime", status.created_at);
timestamp.appendChild(permalink);
let main = document.createElement("main");
main.setAttribute("itemprop", "text");
main.innerHTML = status.content;
let interactions = document.createElement("footer");
if (status.favourites_count > 0) {
let faves = document.createElement("a");
faves.className = "faves";
faves.setAttribute("href", `${status.url}/favourites`);
faves.setAttribute("title", `Favorites from ${instance}`);
faves.textContent = status.favourites_count;
interactions.appendChild(faves);
}
let comment = document.createElement("article");
//comment.id = `comment-${ status.id }`;
comment.className = isReply ? "comment comment-reply" : "comment";
comment.setAttribute("itemprop", "comment");
comment.setAttribute("itemtype", "http://schema.org/Comment");
comment.appendChild(avatar);
comment.appendChild(header);
comment.appendChild(timestamp);
comment.appendChild(main);
comment.appendChild(interactions);
if (op === true) {
comment.classList.add("op");
avatar.classList.add("op");
avatar.setAttribute(
"title",
"Blog post author; " + avatar.getAttribute("title")
);
instanceBadge.classList.add("op");
instanceBadge.setAttribute(
"title",
"Blog post author: " + instanceBadge.getAttribute("title")
);
}
// sanitize the comment
const safeComment = transform(comment.outerHTML, sanitize({
allowedTags: ['a', 'b', 'i', 'em', 'strong', 'p', 'br', 'img', 'code', 'pre', 's'],
allowedAttributes: {
a: ['href', 'title', 'rel'],
img: ['src', 'alt', 'width', 'height'],
time: ['datetime']
},
allowedSchemes: ['http', 'https']
}));
commentsWrapper.innerHTML += safeComment;
});
} else {
commentsWrapper.innerHTML = '<p>No comments yet</p>'
}
document.getElementById('load-comment').outerHTML = ''
document.getElementById('comments-wrapper').innerHTML += `<br><p><button class="addComment">进行评论</button></p>`
document.getElementById('comments-wrapper').innerHTML += `
<dialog id="comment-dialog">
<div class="dialog-title">
<b class="">Make a comment</b>
<button title="Cancel" id="close" class="">&times;</button>
</div>
<p>
Comments are powered by the <a href="https://hatsu.cli.rs">Hatsu</a>, which is a federated comment system backed by Fediverse that allows you to interact with posts across different instances.
You can use your existing Fediverse account to comment on this post, to do so enter the instance address of your Fediverse account below:
<p>
<p class="input-row">
<input type="text" inputmode="url" autocapitalize="none" autocomplete="off"
value="${localStorage.getItem("url") ?? ''}" id="instanceName"
placeholder="例如mastodon.social">
<button class="button" id="go">前往</button>
</p>
<p>或是复制帖文的地址并通过自己网站的搜索框抓取这篇帖子进行互动:</p>
<p class="input-row">
<input type="text" readonly id="copymInput" value="${url}">
<button class="button" id="copym">复制</button>
</p>
<p>账号所在的实例使用 Misskey、Pleroma 等平台的用户,上面的方式可能不工作,请手动复制下面的链接到站点搜索框手动抓取进行互动:</p>
<p class="input-row">
<input type="text" readonly id="copygInput" value="${hatsuHost}/posts/${url}">
<button class="button" id="copyg">复制</button>
</p>
</dialog>`
const dialog = document.getElementById('comment-dialog');
// open dialog on button click
Array.from(document.getElementsByClassName("addComment")).forEach(button => button.addEventListener('click', () => {
dialog.showModal();
// this is a very very crude way of not focusing the field on a mobile device.
// the reason we don't want to do this, is because that will push the modal out of view
if (dialog.getBoundingClientRect().y > 100) {
document.getElementById("instanceName").focus();
}
}));
// when click on 'Go' button: go to the instance specified by the user
document.getElementById('go').addEventListener('click', () => {
let instanceURL = document.getElementById('instanceName').value.trim();
if (instanceURL === '') {
// bail out - window.alert is not very elegant, but it works
window.alert("请填入你的实例地址!");
return;
}
// store the url in the local storage for next time
localStorage.setItem('mastodonUrl', instanceURL);
if (!instanceURL.startsWith('https://')) {
instanceURL = `https://${instanceURL}`;
}
window.open(`${instanceURL}/authorize_interaction?uri=${url}`, '_blank');
});
// also when pressing enter in the input field
document.getElementById('instanceName').addEventListener('keydown', e => {
if (e.key === 'Enter') {
document.getElementById('go').dispatchEvent(new Event('click'));
}
});
// copy tye post's url when pressing copy
document.getElementById('copym').addEventListener('click', () => {
// select the input field, both for visual feedback, and so that the user can use CTRL/CMD+C for manual copying, if they don't trust you
document.getElementById('copymInput').select();
navigator.clipboard.writeText(url);
// Confirm this by changing the button text
document.getElementById('copym').innerHTML = '已复制!';
// restore button text after a second.
window.setTimeout(() => {
document.getElementById('copym').innerHTML = '复制';
}, 1000);
});
// copy tye post's url when pressing copy
document.getElementById('copyg').addEventListener('click', () => {
// select the input field, both for visual feedback, and so that the user can use CTRL/CMD+C for manual copying, if they don't trust you
document.getElementById('copygInput').select();
navigator.clipboard.writeText(url);
// Confirm this by changing the button text
document.getElementById('copyg').innerHTML = '已复制!';
// restore button text after a second.
window.setTimeout(() => {
document.getElementById('copyg').innerHTML = '复制';
}, 1000);
});
// close dialog on button click, or escape button
document.getElementById('close').addEventListener('click', () => {
dialog.close();
});
dialog.addEventListener('keydown', e => {
if (e.key === 'Escape') dialog.close();
});
// Close dialog, if clicked on backdrop
dialog.addEventListener('click', event => {
var rect = dialog.getBoundingClientRect();
var isInDialog =
rect.top <= event.clientY
&& event.clientY <= rect.top + rect.height
&& rect.left <= event.clientX
&& event.clientX <= rect.left + rect.width;
if (!isInDialog) {
dialog.close();
}
})
}
)
}
document.getElementById("load-comment").addEventListener("click", loadComments);
</script>