/** * @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 { /** * 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 = ({ post, postLinkDetail, width, onLoad, toggleVote, onDeleted, ...props }) => { // Variables const height = post.has_media ? Math.min( Math.floor(width / (post as Post).aspect_ratio), MAX_MEDIA_DIMENSION, ) : 0; const AvatarContainer = post.poster_id === null ? "div" : IonRouterLink; // Hooks const id = useId(); const [time, setTime] = useState(); 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) => { 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 ( {post.has_media && (
).blur_hash} height={height} width={width} /> {media !== undefined && (
{(() => { switch (media.category) { case MediaCategory.IMAGE: return ( Post media ); case MediaCategory.VIDEO: return (
)}
)}

{formatScalar(post.views)}

{formatDistance(post.distance, measurementSystem)}

{time !== undefined && ( <>

{time}

)}

{formatScalar(post.comments)}

{ event.preventDefault(); toggleVote(true); }} >

{formatScalar(post.upvotes - post.downvotes)}

{ event.preventDefault(); toggleVote(false); }} > event.preventDefault()} id={`${id}-options`} > { event.preventDefault(); sharePost(); }} > Share { event.preventDefault(); reportPost(); }} > Report {onDeleted !== undefined && ( { event.preventDefault(); deletePost(); }} > Delete )}
); };