'use client'; import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons'; import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react'; import { Post, LinkPreviewMediaItem } from '@/lib/types'; import { useAuth } from '@/lib/contexts/AuthContext'; import { useToast } from '@/lib/contexts/ToastContext'; import { VideoEmbed } from '@/components/VideoEmbed'; import BlurredImage from '@/components/BlurredImage'; import BlurredVideo from '@/components/BlurredVideo'; import { useFormattedHandle } from '@/lib/utils/handle'; import { useDomain } from '@/lib/contexts/ConfigContext'; import { signedAPI } from '@/lib/api/signed-fetch'; import type { LinkPreviewData } from '@/lib/media/linkPreview'; import { AvatarImage } from '@/components/AvatarImage'; // 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)} />
); } const EMBED_VIDEO_REGEX = /(youtube\.com|youtu\.be|vimeo\.com)/; function isPlaceholderPreview(post: Post): boolean { if (!post.linkPreviewUrl) { return false; } try { const hostname = new URL( post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}` ).hostname.replace(/^www\./, '').toLowerCase(); const title = post.linkPreviewTitle?.trim().toLowerCase() || ''; const hasRichData = Boolean( post.linkPreviewDescription || post.linkPreviewImage || post.linkPreviewVideoUrl || (post.linkPreviewMedia && post.linkPreviewMedia.length > 0) ); if (hasRichData) { return false; } return ( !title || title === 'reddit' || title === hostname || title === `www.${hostname}` ); } catch { return false; } } function LinkPreviewGallery({ media, alt, compact = false, }: { media: LinkPreviewMediaItem[]; alt: string; compact?: boolean; }) { const visibleMedia = media.slice(0, compact ? 3 : 4); return (
{visibleMedia.map((item, index) => (
{alt} {index === visibleMedia.length - 1 && media.length > visibleMedia.length && ( +{media.length - visibleMedia.length} )}
))}
); } interface PostCardProps { post: Post; onLike?: (id: string, currentLiked: boolean) => void; onRepost?: (id: string, currentReposted: boolean) => Promise | void; onComment?: (post: Post) => void; onDelete?: (id: string) => void; onHide?: (id: string) => void; // Called when post should be hidden (block/mute) isDetail?: boolean; showThread?: boolean; // Show parent post inline as a thread isThreadParent?: boolean; // This post is being shown as a parent in a thread isEmbedded?: boolean; parentPostAuthorId?: string; // ID of the parent post's author (for allowing deletion of replies) } export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, isEmbedded = false, parentPostAuthorId }: PostCardProps) { const { user: currentUser, did, handle: currentUserHandle, isIdentityUnlocked } = useAuth(); const { showToast } = useToast(); const router = useRouter(); const [liked, setLiked] = useState(post.isLiked || false); const [likesCount, setLikesCount] = useState(post.likesCount || 0); const [likePending, setLikePending] = useState(false); const [reposted, setReposted] = useState(post.isReposted || false); const [repostsCount, setRepostsCount] = useState(post.repostsCount || 0); const [repostPending, setRepostPending] = useState(false); const [reporting, setReporting] = useState(false); const [deleting, setDeleting] = useState(false); const [showMenu, setShowMenu] = useState(false); const [hydratedPreview, setHydratedPreview] = useState(null); const domain = useDomain(); const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain); const isOwnOrOwnedBotPost = Boolean( currentUser && ( currentUser.id === post.author.id || (post.bot && currentUser.id === post.bot.ownerId) || (post.author.id.startsWith('swarm:') && ( post.author.handle === currentUser.handle || post.author.handle === `${currentUser.handle}@${domain}` )) ) ); const canDeletePost = Boolean( currentUser && ( isOwnOrOwnedBotPost || (parentPostAuthorId && currentUser.id === parentPostAuthorId) ) ); // Sync state if post changes (e.g. after a re-render from parent) useEffect(() => { setLiked(post.isLiked || false); setLikesCount(post.likesCount || 0); setReposted(post.isReposted || false); setRepostsCount(post.repostsCount || 0); }, [post.isLiked, post.likesCount, post.isReposted, post.repostsCount, post.id]); useEffect(() => { let cancelled = false; const missingPreviewData = Boolean( post.linkPreviewUrl && !post.linkPreviewTitle && !post.linkPreviewDescription && !post.linkPreviewImage && !post.linkPreviewVideoUrl && (!post.linkPreviewMedia || post.linkPreviewMedia.length === 0) ); const placeholderPreviewData = isPlaceholderPreview(post); if ((!missingPreviewData && !placeholderPreviewData) || !post.linkPreviewUrl) { setHydratedPreview(null); return; } (async () => { try { const res = await fetch(`/api/media/preview?url=${encodeURIComponent(post.linkPreviewUrl!)}`); if (!res.ok) return; const data = await res.json(); if (!cancelled) { setHydratedPreview(data); } } catch { } })(); return () => { cancelled = true; }; }, [ post.linkPreviewUrl, post.linkPreviewTitle, post.linkPreviewDescription, post.linkPreviewImage, post.linkPreviewVideoUrl, post.linkPreviewMedia, ]); 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 = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (likePending) { return; } if (!isIdentityUnlocked) { showToast('Please log in to like posts', 'error'); return; } const currentLiked = liked; const currentLikesCount = likesCount; const nextLiked = !currentLiked; const nextLikesCount = Math.max(0, currentLikesCount + (currentLiked ? -1 : 1)); setLiked(nextLiked); setLikesCount(nextLikesCount); setLikePending(true); try { await onLike?.(post.id, currentLiked); } catch (error) { setLiked(currentLiked); setLikesCount(currentLikesCount); showToast(error instanceof Error ? error.message : 'Failed to update like', 'error'); } finally { setLikePending(false); } }; const handleRepost = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (repostPending) { return; } if (!isIdentityUnlocked) { showToast('Please log in to repost', 'error'); return; } const currentReposted = reposted; const currentRepostsCount = repostsCount; const nextReposted = !currentReposted; const nextRepostsCount = Math.max(0, currentRepostsCount + (currentReposted ? -1 : 1)); setReposted(nextReposted); setRepostsCount(nextRepostsCount); setRepostPending(true); try { await onRepost?.(post.id, currentReposted); } catch (error) { setReposted(currentReposted); setRepostsCount(currentRepostsCount); showToast(error instanceof Error ? error.message : 'Failed to update repost', 'error'); } finally { setRepostPending(false); } }; const handleComment = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); // Navigate to post detail page router.push(postUrl); }; 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 || !did || !currentUserHandle) return; setReporting(true); try { const res = await signedAPI.report( 'post', post.id, reason, did, currentUserHandle ); 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; if (!did || !currentUserHandle) return; setDeleting(true); try { const res = await signedAPI.deletePost(post.id, did, currentUserHandle); 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 || !did || !currentUserHandle) { showToast('Please log in to block users', 'error'); return; } try { const res = await signedAPI.blockUser(post.author.handle, did, currentUserHandle); 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 || !did || !currentUserHandle) { 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 signedAPI.blockUser(post.author.handle, did, currentUserHandle); 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 || !did || !currentUserHandle) { 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 signedAPI.muteNode(nodeDomain, did, currentUserHandle); 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 = `/u/${post.author.handle}/posts/${post.id}`; // Get the full handle for profile links (includes domain for remote users) const getProfileHandle = () => { // If handle already has domain, use it if (post.author.handle.includes('@')) { return post.author.handle; } // If this is a swarm post from a DIFFERENT node, append the node domain if (post.nodeDomain && post.nodeDomain !== domain) { return `${post.author.handle}@${post.nodeDomain}`; } // Local user return post.author.handle; }; const profileHandle = getProfileHandle(); // 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}; }); }; // Build a synthetic replyTo for swarm replies const effectiveReplyTo = post.replyTo || (post.swarmReplyToId && post.swarmReplyToAuthor ? { id: post.swarmReplyToId, content: post.swarmReplyToContent || '', createdAt: post.createdAt, // Use same time as approximation likesCount: 0, repostsCount: 0, repliesCount: 0, author: typeof post.swarmReplyToAuthor === 'string' ? JSON.parse(post.swarmReplyToAuthor) : post.swarmReplyToAuthor, isSwarm: true, nodeDomain: (typeof post.swarmReplyToAuthor === 'string' ? JSON.parse(post.swarmReplyToAuthor) : post.swarmReplyToAuthor)?.nodeDomain, } as Post : null); const replyToHandle = effectiveReplyTo?.author?.handle ? useFormattedHandle(effectiveReplyTo.author.handle, effectiveReplyTo.nodeDomain) : ''; const repostHandle = useFormattedHandle(post.author.handle, post.nodeDomain); const hasOwnContent = decodeHtmlEntities(post.content).trim().length > 0; const isRepostEvent = Boolean(post.repostOf); const effectivePreview = { url: hydratedPreview?.url || post.linkPreviewUrl || null, title: hydratedPreview?.title || post.linkPreviewTitle || null, description: hydratedPreview?.description || post.linkPreviewDescription || null, image: hydratedPreview?.image || post.linkPreviewImage || null, type: hydratedPreview?.type || post.linkPreviewType || null, videoUrl: hydratedPreview?.videoUrl || post.linkPreviewVideoUrl || null, media: hydratedPreview?.media || post.linkPreviewMedia || null, }; const rawPreviewMedia = (() => { const mediaJson = (post as Post & { linkPreviewMediaJson?: string | null }).linkPreviewMediaJson; if (!mediaJson) { return []; } try { const parsed = JSON.parse(mediaJson); return Array.isArray(parsed) ? parsed : []; } catch { return []; } })(); const previewMedia = (effectivePreview.media && effectivePreview.media.length > 0) ? effectivePreview.media : rawPreviewMedia.length > 0 ? rawPreviewMedia : effectivePreview.image ? [{ url: effectivePreview.image }] : []; const previewImage = previewMedia[0]?.url || effectivePreview.image || null; const isEmbeddedVideo = Boolean(effectivePreview.url && effectivePreview.url.match(EMBED_VIDEO_REGEX)); const isRichVideoPreview = effectivePreview.type === 'video' && Boolean(effectivePreview.videoUrl); const isGalleryPreview = effectivePreview.type === 'gallery' && previewMedia.length > 1; const renderLinkPreviewCard = (compact = false) => { if (!effectivePreview.url || isEmbeddedVideo) { return null; } return ( e.stopPropagation()} > {isRichVideoPreview && effectivePreview.videoUrl ? (

) : isGalleryPreview ? ( ) : previewImage ? ( ) : null}
{effectivePreview.title ? decodeHtmlEntities(effectivePreview.title) : ''}
{effectivePreview.description && (
{decodeHtmlEntities(effectivePreview.description)}
)}
{new URL(effectivePreview.url.startsWith('http') ? effectivePreview.url : `https://${effectivePreview.url}`).hostname}
); }; // If this is a thread parent being rendered, just render the article if (isThreadParent) { return (
e.stopPropagation()}>
e.stopPropagation()}> {post.author.displayName || post.author.handle} {authorHandle}
{renderContent(post.content, post.linkPreviewUrl ?? undefined)}
); } if (isRepostEvent && post.repostOf) { return ( <>
e.stopPropagation()}> {post.author.displayName || post.author.handle} reposted {repostHandle} · {formatTime(post.createdAt)}
{hasOwnContent && (
{renderContent(post.content, post.linkPreviewUrl ?? undefined)}
)}
); } return ( <> {/* Show parent post as part of thread - only on detail page */} {showThread && effectiveReplyTo && isDetail && (
)}
{!isDetail && }
e.stopPropagation()}>
e.stopPropagation()}> {post.author.displayName || post.author.handle} {(post.bot || post.author.isBot) && ( AI Account )}
{authorHandle} · {formatTime(post.createdAt)}
{currentUser && !isOwnOrOwnedBotPost && (
{showMenu && ( <>
{ e.preventDefault(); e.stopPropagation(); setShowMenu(false); }} />
{(post.nodeDomain || post.author.handle.includes('@')) && ( )}
)}
)}
{effectiveReplyTo && !showThread && (
Replying to e.stopPropagation()}>{replyToHandle}
)}
{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; }} /> ) : ( )}
); })}
)} {post.linkPreviewUrl && ( )} {renderLinkPreviewCard()}
{!isOwnOrOwnedBotPost && ( )} {canDeletePost && ( )}
); }