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

592 lines
15 KiB
TypeScript

/**
* @file Post card component
*/
import {
IonButton,
IonCard,
IonCardContent,
IonIcon,
IonItem,
IonList,
IonPopover,
IonRouterLink,
useIonActionSheet,
} from "@ionic/react";
import {
arrowDownOutline,
arrowDownSharp,
arrowUpOutline,
arrowUpSharp,
chatbubblesOutline,
chatbubblesSharp,
ellipsisVerticalOutline,
ellipsisVerticalSharp,
eyeOutline,
eyeSharp,
locationOutline,
locationSharp,
shareSocialOutline,
shareSocialSharp,
timeOutline,
timeSharp,
trashBinOutline,
trashBinSharp,
warningOutline,
warningSharp,
} from "ionicons/icons";
import {Duration} from "luxon";
import {
FC,
HTMLAttributes,
MouseEvent,
useEffect,
useId,
useState,
} from "react";
import {useHistory} from "react-router-dom";
import {Avatar} from "~/components/avatar";
import {Blurhash} from "~/components/blurhash";
import {Markdown} from "~/components/markdown";
import styles from "~/components/post-card.module.css";
import {getCategory, MAX_MEDIA_DIMENSION} from "~/lib/media";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {usePersistentStore} from "~/lib/stores/persistent";
import {client} from "~/lib/supabase";
import {GlobalMessageMetadata, MediaCategory, Post} from "~/lib/types";
import {formatDistance, formatDuration, formatScalar} from "~/lib/utils";
/**
* Copied link message metadata
*/
const COPIED_LINK_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("post-card.copied-link"),
name: "Copied link",
description: "The link to the post has been copied to your clipboard.",
};
/**
* Already reported message metadata
*/
const ALREADY_REPORTED_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("post-card.already-reported"),
name: "Already reported",
description: "The post has already been reported.",
};
/**
* New report message metadata
*/
const NEW_REPORT_MESSAGE_METADATA: GlobalMessageMetadata = {
symbol: Symbol("post-card.new-report"),
name: "New report",
description: "The post has been reported. Thank you for your feedback.",
};
/**
* Post card component props
*/
interface PostCardProps extends HTMLAttributes<HTMLIonCardElement> {
/**
* Post
*/
post: Post;
/**
* Whether or not the post will link to the post detail page
*/
postLinkDetail: boolean;
/**
* Post card width
*/
width: number;
/**
* Post load event handler
*/
onLoad?: () => void;
/**
* Toggle a vote on the post
* @param upvote Whether the vote is an upvote or a downvote
*/
toggleVote: (upvote: boolean) => void;
/**
* Post deleted event handler
*/
onDeleted?: () => void;
}
/**
* Post card component
* @param props Props
* @returns JSX
*/
export const PostCard: FC<PostCardProps> = ({
post,
postLinkDetail,
width,
onLoad,
toggleVote,
onDeleted,
...props
}) => {
// Variables
const height = post.has_media
? Math.min(
Math.floor(width / (post as Post<true>).aspect_ratio),
MAX_MEDIA_DIMENSION,
)
: 0;
const AvatarContainer = post.poster_id === null ? "div" : IonRouterLink;
// Hooks
const id = useId();
const [time, setTime] = useState<string | undefined>();
const [media, setMedia] = useState<
| {
category: MediaCategory;
url: string;
}
| undefined
>();
const history = useHistory();
const [present] = useIonActionSheet();
const setMessage = useEphemeralStore(state => state.setMessage);
const showAmbientEffect = usePersistentStore(
state => state.showAmbientEffect,
);
const measurementSystem = usePersistentStore(
state => state.measurementSystem,
);
// Effects
useEffect(() => {
// Recompute ago every five seconds
updateAgo();
setInterval(updateAgo, 5000);
}, []);
useEffect(() => {
(async () => {
if (!post.has_media) {
setMedia(undefined);
if (!post.has_media) {
onLoad?.();
}
return;
}
// Load media
const urls = [
client.storage.from("media").getPublicUrl(`posts/${post.id}`, {
transform: {
quality: 90,
height,
width: Math.floor(width),
},
}).data.publicUrl,
client.storage.from("media").getPublicUrl(`posts/${post.id}`).data
.publicUrl,
];
// Fetch the media
let res: Response | undefined;
for (const url of urls) {
res = await fetch(url);
if (res.ok) {
break;
}
}
if (res === undefined) {
setMedia(undefined);
return;
}
const blob = await res.blob();
// Get the category
const category = getCategory(blob.type);
if (category === undefined) {
setMedia(undefined);
return;
}
// Create an object URL for the blob
const url = URL.createObjectURL(blob);
setMedia({
category,
url,
});
// Emit the load event
onLoad?.();
})();
}, [post]);
// Methods
/**
* Update the ago time
*/
const updateAgo = () => {
const duration = Date.now() - new Date(post.created_at).getTime();
setTime(
Duration.fromMillis(duration).as("days") < 1
? `${formatDuration(duration)} ago`
: new Date(post.created_at).toLocaleDateString(),
);
};
/**
* Card click event handler
* @param event Event
*/
const onCardClick = (event: MouseEvent<HTMLIonCardElement>) => {
if (event.defaultPrevented || !postLinkDetail) {
return;
}
// Go to the post detail page
history.push(`/posts/${post.id}`);
};
/**
* Share the post
*/
const sharePost = async () => {
// Generate the URL
const url = new URL(`/posts/${post.id}`, window.location.origin);
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 post
* @returns Promise
*/
const reportPost = () =>
present({
header: "Report Post",
subHeader:
"Are you sure you want to report this post? This action cannot be undone.",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Report",
role: "destructive",
/**
* Post report handler
*/
handler: async () => {
// Insert the report
const {error} = await client.from("post_reports").insert({
// eslint-disable-next-line camelcase
post_id: post.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 post
* @returns Promise
*/
const deletePost = () =>
present({
header: "Delete Post",
subHeader:
"Are you sure you want to delete this post? This action cannot be undone.",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Delete",
role: "destructive",
/**
* Comment delete handler
*/
handler: onDeleted,
},
],
});
return (
<IonCard
{...props}
onClick={onCardClick}
className={`cursor-pointer dark:text-neutral-300 overflow-hidden rounded-xl text-neutral-700 ${
props.className ?? ""
}`}
>
{post.has_media && (
<div
className="relative"
style={{
height,
width,
}}
>
<Blurhash
className="absolute"
ambient={showAmbientEffect}
hash={(post as Post<true>).blur_hash}
height={height}
width={width}
/>
{media !== undefined && (
<div className="absolute animate-duration-500 animate-fade-in h-full overflow-hidden w-full">
{(() => {
switch (media.category) {
case MediaCategory.IMAGE:
return (
<img
alt="Post media"
height={height}
src={media.url}
width={width}
/>
);
case MediaCategory.VIDEO:
return (
<video
autoPlay
height={height}
loop
muted
src={media.url}
width={width}
/>
);
}
})()}
</div>
)}
</div>
)}
<IonCardContent className="p-4">
<div className="flex flex-row items-center justify-between w-full">
<AvatarContainer
className="h-10 w-10"
{...(post.poster_id === null
? {}
: {
routerLink: `/users/${post.poster_id}`,
})}
>
<Avatar
profile={{
emoji: post.poster_emoji ?? undefined,
color: post.poster_color ?? undefined,
}}
/>
</AvatarContainer>
<div className="flex flex-row items-center justify-center h-full">
<IonIcon className="text-[1.4rem]" ios={eyeOutline} md={eyeSharp} />
<p className="!mb-0 !ml-1.5 !mr-4 !mt-0.5">
{formatScalar(post.views)}
</p>
<IonIcon
className="text-[1.4rem]"
ios={locationOutline}
md={locationSharp}
/>
<p className="!mb-0 !ml-1.5 !mr-4 !mt-0.5">
{formatDistance(post.distance, measurementSystem)}
</p>
{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={post.content} />
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center justify-center px-1">
<IonIcon
className="text-[1.4rem]"
color="medium"
slot="start"
ios={chatbubblesOutline}
md={chatbubblesSharp}
/>
<p className="!mb-0 !ml-1.5">{formatScalar(post.comments)}</p>
</div>
<div className="flex flex-row items-center">
<IonButton
className={`m-0 ${styles.iconButton}`}
color={post.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(post.upvotes - post.downvotes)}
</p>
<IonButton
className={`m-0 ${styles.iconButton}`}
color={post.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();
sharePost();
}}
>
<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();
reportPost();
}}
>
<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();
deletePost();
}}
>
<IonIcon
slot="start"
ios={trashBinOutline}
md={trashBinSharp}
/>
Delete
</IonButton>
</IonItem>
)}
</IonList>
</IonPopover>
</div>
</div>
</IonCardContent>
</IonCard>
);
};