Files
HKSingleParty/99_references/beacon-main/src/components/comment-card.tsx
2025-05-28 09:55:51 +08:00

398 lines
10 KiB
TypeScript

/**
* @file Comment card component
*/
import {
IonButton,
IonCard,
IonCardContent,
IonIcon,
IonItem,
IonList,
IonPopover,
IonRouterLink,
useIonActionSheet,
} from "@ionic/react";
import {
arrowDownOutline,
arrowDownSharp,
arrowUpOutline,
arrowUpSharp,
ellipsisVerticalOutline,
ellipsisVerticalSharp,
shareSocialOutline,
shareSocialSharp,
timeOutline,
timeSharp,
trashBinOutline,
trashBinSharp,
warningOutline,
warningSharp,
} from "ionicons/icons";
import {Duration} from "luxon";
import {FC, HTMLAttributes, useEffect, useId, useState} from "react";
import {Avatar} from "~/components/avatar";
import styles from "~/components/comment-card.module.css";
import {Markdown} from "~/components/markdown";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {client} from "~/lib/supabase";
import {Comment, GlobalMessageMetadata} from "~/lib/types";
import {formatDuration, formatScalar} from "~/lib/utils";
/**
* Copied link message metadata
*/
const COPIED_LINK_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("comment-card.copied-link"),
name: "Copied link",
description: "The link to the comment has been copied to your clipboard.",
};
/**
* Already reported message metadata
*/
const ALREADY_REPORTED_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("comment-card.already-reported"),
name: "Already reported",
description: "The comment has already been reported.",
};
/**
* New report message metadata
*/
const NEW_REPORT_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("comment-card.new-report"),
name: "New report",
description: "The comment has been reported. Thank you for your feedback.",
};
/**
* Comment card component props
*/
interface CommentCardProps extends HTMLAttributes<HTMLIonCardElement> {
/**
* Comment
*/
comment: Comment;
/**
* Comment load event handler
*/
onLoad?: () => void;
/**
* Toggle a vote on the comment
* @param upvote Whether the vote is an upvote or a downvote
*/
toggleVote: (upvote: boolean) => void;
/**
* Comment deleted event handler
*/
onDeleted?: () => void;
}
/**
* Comment card component
* @param props Props
* @returns JSX
*/
export const CommentCard: FC<CommentCardProps> = ({
comment,
onLoad,
toggleVote,
onDeleted,
...props
}) => {
// Variables
const AvatarContainer = comment.commenter_id === null ? "div" : IonRouterLink;
// Hooks
const id = useId();
const [time, setTime] = useState<string | undefined>();
const [present] = useIonActionSheet();
const setMessage = useEphemeralStore(state => state.setMessage);
// Effects
useEffect(() => {
// Recompute ago every five seconds
updateAgo();
setInterval(updateAgo, 5000);
// Emit the load event
onLoad?.();
}, []);
// Methods
/**
* Update the ago time
*/
const updateAgo = () => {
const duration = Date.now() - new Date(comment.created_at).getTime();
setTime(
Duration.fromMillis(duration).as("days") < 1
? `${formatDuration(duration)} ago`
: new Date(comment.created_at).toLocaleDateString(),
);
};
/**
* Share the comment
*/
const shareComment = async () => {
// Generate the URL
const url = new URL(`/posts/${comment.post_id}`, window.location.origin);
url.search = new URLSearchParams({
comment: comment.id.toString(),
}).toString();
const strUrl = url.toString();
// Share
await (navigator.share === undefined
? navigator.clipboard.writeText(strUrl)
: navigator.share({
url: strUrl,
}));
// Display the message
setMessage(COPIED_LINK_MESSAGE_METADATA);
};
/**
* Report the comment
* @returns Promise
*/
const reportComment = () =>
present({
header: "Report Comment",
subHeader:
"Are you sure you want to report this comment? This action cannot be undone.",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Report",
role: "destructive",
/**
* Comment report handler
*/
handler: async () => {
// Insert the report
const {error} = await client.from("comment_reports").insert({
// eslint-disable-next-line camelcase
comment_id: comment.id,
});
// Handle error
if (error !== null) {
if (error.code === "23505") {
// Display the message
setMessage(ALREADY_REPORTED_MESSAGE_METADATA);
}
return;
}
// Display the message
setMessage(NEW_REPORT_MESSAGE_METADATA);
},
},
],
});
/**
* Delete the comment
* @returns Promise
*/
const deleteComment = () =>
present({
header: "Delete Comment",
subHeader:
"Are you sure you want to delete this comment? This action cannot be undone.",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Delete",
role: "destructive",
/**
* Comment delete handler
*/
handler: onDeleted,
},
],
});
return (
<IonCard
{...props}
className={`cursor-pointer dark:text-neutral-300 overflow-hidden rounded-xl text-neutral-700 ${
props.className ?? ""
}`}
>
<IonCardContent className="p-4">
<div className="flex flex-row items-center justify-between w-full">
<AvatarContainer
className="h-8 w-8"
{...(comment.commenter_id === null
? {}
: {
routerLink: `/users/${comment.commenter_id}`,
})}
>
<Avatar
profile={{
emoji: comment.commenter_emoji ?? undefined,
color: comment.commenter_color ?? undefined,
}}
/>
</AvatarContainer>
<div className="flex flex-row items-center justify-center h-full">
{time !== undefined && (
<>
<IonIcon
className="text-[1.4rem]"
ios={timeOutline}
md={timeSharp}
/>
<p className="!mb-0 !ml-1.5 !mt-0.5">{time}</p>
</>
)}
</div>
</div>
<Markdown className="mt-4 mb-1" raw={comment.content} />
<div className="flex flex-row items-center justify-between">
<div />
<div className="flex flex-row items-center">
<IonButton
className={`m-0 ${styles.iconButton}`}
color={comment.upvote === true ? "success" : "medium"}
fill="clear"
onClick={event => {
event.preventDefault();
toggleVote(true);
}}
>
<IonIcon
slot="icon-only"
ios={arrowUpOutline}
md={arrowUpSharp}
/>
</IonButton>
<p className="!mb-0 !mx-1.5">
{formatScalar(comment.upvotes - comment.downvotes)}
</p>
<IonButton
className={`m-0 ${styles.iconButton}`}
color={comment.upvote === false ? "danger" : "medium"}
fill="clear"
onClick={event => {
event.preventDefault();
toggleVote(false);
}}
>
<IonIcon
slot="icon-only"
ios={arrowDownOutline}
md={arrowDownSharp}
/>
</IonButton>
<IonButton
className={`m-0 ml-1.5 ${styles.iconButton}`}
color="medium"
fill="clear"
onClick={event => event.preventDefault()}
id={`${id}-options`}
>
<IonIcon
className="text-[1.4rem]"
slot="icon-only"
ios={ellipsisVerticalOutline}
md={ellipsisVerticalSharp}
/>
</IonButton>
<IonPopover trigger={`${id}-options`} triggerAction="click">
<IonList>
<IonItem>
<IonButton
className="w-full"
fill="clear"
onClick={event => {
event.preventDefault();
shareComment();
}}
>
<IonIcon
slot="start"
ios={shareSocialOutline}
md={shareSocialSharp}
/>
Share
</IonButton>
</IonItem>
<IonItem lines={onDeleted === undefined ? "none" : undefined}>
<IonButton
className="w-full"
color="danger"
fill="clear"
onClick={event => {
event.preventDefault();
reportComment();
}}
>
<IonIcon
slot="start"
ios={warningOutline}
md={warningSharp}
/>
Report
</IonButton>
</IonItem>
{onDeleted !== undefined && (
<IonItem lines="none">
<IonButton
className="w-full"
color="danger"
fill="clear"
onClick={event => {
event.preventDefault();
deleteComment();
}}
>
<IonIcon
slot="start"
ios={trashBinOutline}
md={trashBinSharp}
/>
Delete
</IonButton>
</IonItem>
)}
</IonList>
</IonPopover>
</div>
</div>
</IonCardContent>
</IonCard>
);
};