398 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|