'use client'; import { useState, useEffect } from 'react'; import Link from 'next/link'; import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons'; import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react'; import { Post } from '@/lib/types'; import { useAuth } from '@/lib/contexts/AuthContext'; import { useToast } from '@/lib/contexts/ToastContext'; import { VideoEmbed } from '@/components/VideoEmbed'; import BlurredVideo from '@/components/BlurredVideo'; import { formatFullHandle } from '@/lib/utils/handle'; // Component for link preview image that hides on error function LinkPreviewImage({ src, alt }: { src: string; alt: string }) { const [hasError, setHasError] = useState(false); if (hasError) return null; return (
{alt} setHasError(true)} />
); } interface PostCardProps { post: Post; onLike?: (id: string, currentLiked: boolean) => void; onRepost?: (id: string, currentReposted: boolean) => void; onComment?: (post: Post) => void; onDelete?: (id: string) => void; onHide?: (id: string) => void; // Called when post should be hidden (block/mute) isDetail?: boolean; } export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail }: PostCardProps) { const { user: currentUser } = useAuth(); const { showToast } = useToast(); const [liked, setLiked] = useState(post.isLiked || false); const [reposted, setReposted] = useState(post.isReposted || false); const [reporting, setReporting] = useState(false); const [deleting, setDeleting] = useState(false); const [showMenu, setShowMenu] = useState(false); // Sync state if post changes (e.g. after a re-render from parent) useEffect(() => { setLiked(post.isLiked || false); setReposted(post.isReposted || false); }, [post.isLiked, post.isReposted, post.id]); const formatTime = (dateStr: string | Date) => { const date = new Date(dateStr); if (isNaN(date.getTime())) { return ''; } const now = new Date(); const diff = now.getTime() - date.getTime(); // If post is in the future (minor clock skew), show "now" if (diff < 0) { return 'now'; } const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (seconds < 60) return 'now'; if (minutes < 60) return `${minutes}m`; if (hours < 24) return `${hours}h`; if (days < 7) return `${days}d`; return date.toLocaleDateString(); }; const handleLike = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const currentLiked = liked; setLiked(!currentLiked); onLike?.(post.id, currentLiked); // Pass current state before toggle }; const handleRepost = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const currentReposted = reposted; setReposted(!currentReposted); onRepost?.(post.id, currentReposted); // Pass current state before toggle }; const handleComment = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onComment?.(post); }; const handleReport = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (reporting) return; const reason = window.prompt('Why are you reporting this post?'); if (!reason) return; setReporting(true); try { const res = await fetch('/api/reports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }), }); if (!res.ok) { if (res.status === 401) { showToast('Please log in to report.', 'error'); } else { showToast('Report failed. Please try again.', 'error'); } } else { showToast('Report submitted. Thank you.', 'success'); } } catch { showToast('Report failed. Please try again.', 'error'); } finally { setReporting(false); } }; const handleDelete = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (deleting) return; if (!window.confirm('Are you sure you want to delete this post? This action cannot be undone.')) return; setDeleting(true); try { const res = await fetch(`/api/posts/${post.id}`, { method: 'DELETE', }); if (res.ok) { onDelete?.(post.id); } else { const data = await res.json(); showToast(data.error || 'Failed to delete post', 'error'); } } catch { showToast('Failed to delete post', 'error'); } finally { setDeleting(false); } }; const handleBlockUser = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowMenu(false); if (!currentUser) { showToast('Please log in to block users', 'error'); return; } try { const res = await fetch(`/api/users/${post.author.handle}/block`, { method: 'POST', }); if (res.ok) { showToast(`Blocked @${post.author.handle}`, 'success'); onHide?.(post.id); } else { showToast('Failed to block user', 'error'); } } catch { showToast('Failed to block user', 'error'); } }; const handleMuteUser = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowMenu(false); if (!currentUser) { showToast('Please log in to mute users', 'error'); return; } // For now, muting a user is the same as blocking but with different messaging // Could be expanded to just hide posts without breaking follows try { const res = await fetch(`/api/users/${post.author.handle}/block`, { method: 'POST', }); if (res.ok) { showToast(`Muted @${post.author.handle}`, 'success'); onHide?.(post.id); } else { showToast('Failed to mute user', 'error'); } } catch { showToast('Failed to mute user', 'error'); } }; const handleMuteNode = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowMenu(false); if (!currentUser) { showToast('Please log in to mute nodes', 'error'); return; } // Extract node domain from the post const nodeDomain = post.nodeDomain || (post.author.handle.includes('@') ? post.author.handle.split('@')[1] : null); if (!nodeDomain) { showToast('Cannot determine node for this post', 'error'); return; } try { const res = await fetch('/api/settings/muted-nodes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: nodeDomain }), }); if (res.ok) { showToast(`Muted node: ${nodeDomain}`, 'success'); onHide?.(post.id); } else { showToast('Failed to mute node', 'error'); } } catch { showToast('Failed to mute node', 'error'); } }; const postUrl = `/${post.author.handle}/posts/${post.id}`; // Decode HTML entities from federated posts (e.g., &rsquo; -> ') const decodeHtmlEntities = (text: string): string => { const entities: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", '’': '\u2019', // ' '‘': '\u2018', // ' '”': '\u201D', // " '“': '\u201C', // " '–': '\u2013', // – '—': '\u2014', // — '…': '\u2026', // … ' ': ' ', '©': '\u00A9', // © '®': '\u00AE', // ® '™': '\u2122', // ™ '€': '\u20AC', // € '£': '\u00A3', // £ '¥': '\u00A5', // ¥ '¢': '\u00A2', // ¢ }; // First decode named entities let decoded = text; for (const [entity, char] of Object.entries(entities)) { decoded = decoded.replace(new RegExp(entity, 'g'), char); } // Decode numeric entities ({ or {) decoded = decoded.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10))); decoded = decoded.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); // Strip HTML tags (remote posts may contain

,
, etc.) decoded = decoded.replace(//gi, '\n'); decoded = decoded.replace(/<\/p>\s*

/gi, '\n\n'); decoded = decoded.replace(/<[^>]+>/g, ''); return decoded.trim(); }; const renderContent = (content: string, hidePreviewUrl?: string) => { const decoded = decodeHtmlEntities(content); const parts = decoded.split(/(https?:\/\/[^\s]+)/g); return parts.map((part, index) => { if (part.match(/^https?:\/\/[^\s]+$/)) { // If this URL matches the link preview URL, hide it entirely if (hidePreviewUrl && part.includes(hidePreviewUrl.replace(/^https?:\/\/(www\.)?/, '').split('/')[0])) { return null; } // Extract just the domain (TLD) try { const url = new URL(part); const domain = url.hostname.replace(/^www\./, ''); return ( e.stopPropagation()} title={part} > {domain} ); } catch { // Fallback if URL parsing fails return ( e.stopPropagation()} > {part} ); } } // Handle newlines if (part.includes('\n')) { return part.split('\n').map((line, lineIndex, arr) => ( {line} {lineIndex < arr.length - 1 &&
}
)); } return {part}; }); }; return (

{!isDetail && }
e.stopPropagation()}>
{post.author.avatarUrl ? ( {post.author.displayName} ) : ( post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase() )}
e.stopPropagation()}> {post.author.displayName || post.author.handle} {post.bot && ( AI Account )}
{formatFullHandle(post.author.handle)} · {formatTime(post.createdAt)}
{currentUser && currentUser.id !== post.author.id && (
{showMenu && ( <>
{ e.preventDefault(); e.stopPropagation(); setShowMenu(false); }} />
{(post.nodeDomain || post.author.handle.includes('@')) && ( )}
)}
)}
{post.replyTo && (
Replied to e.stopPropagation()}>{formatFullHandle(post.replyTo.author.handle)}
)}
{renderContent(post.content, post.linkPreviewUrl ?? undefined)}
{post.media && post.media.length > 0 && (
{post.media.map((item) => { const isVideo = item.mimeType?.startsWith('video/'); return (
{isVideo ? ( { e.stopPropagation(); const video = e.currentTarget; video.muted = !video.muted; }} /> ) : ( {item.altText )}
); })}
)} {post.linkPreviewUrl && ( )} {post.linkPreviewUrl && !post.linkPreviewUrl.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && ( e.stopPropagation()} > {post.linkPreviewImage && ( )}
{post.linkPreviewTitle ? decodeHtmlEntities(post.linkPreviewTitle) : ''}
{post.linkPreviewDescription && (
{decodeHtmlEntities(post.linkPreviewDescription)}
)}
{new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname}
)}
{(currentUser?.id === post.author.id || (post.bot && currentUser?.id === post.bot.ownerId)) && ( )}
); }