import { useState, useMemo, useEffect } from 'react'; import { Pin, Lock, ExternalLink, Filter } from 'lucide-react'; import type { Post as PostType } from '../../types/api'; import { getImageUrl, getThumbnailUrl } from '../../utils/apiClient'; import { formatAbsoluteTime, formatRelativeTime, formatFileSize, formatDimensions } from '../../utils/formatters'; import { parsePostContent, extractQuoteLinks } from '../../utils/parser'; import { useFilterStore, checkFilters } from '../../stores/filterStore'; import { useLightboxStore } from '../../stores/lightboxStore'; import CachedImage from '../common/CachedImage'; import VideoPlayer from '../media/VideoPlayer'; import FlashPlayer from '../media/FlashPlayer'; import PostPreviewPopup from './PostPreviewPopup'; interface PostProps { post: PostType; board: string; isOP: boolean; allPosts: PostType[]; showFiltered?: boolean; isNew?: boolean; // Highlight as new post since last visit } export default function Post({ post, board, isOP, allPosts, showFiltered = false, isNew = false }: PostProps) { const { filters, hidePost } = useFilterStore(); // Check if post matches any filters const matchedFilter = useMemo(() => { return checkFilters({ com: post.com, name: post.name, trip: post.trip, sub: post.sub }, filters, board); }, [post.com, post.name, post.trip, post.sub, filters, board]); // Hide post if it matches a filter (unless showFiltered is true) if (matchedFilter && !showFiltered) { return (
Post hidden by filter: {matchedFilter.value}
); } const [imageExpanded, setImageExpanded] = useState(false); const [hoveredQuote, setHoveredQuote] = useState(null); const [popupPost, setPopupPost] = useState(null); const hasImage = post.tim && post.ext; const imageUrl = hasImage ? getImageUrl(board, post.tim!, post.ext!) : null; // SWF files don't have thumbnails on 4chan const thumbUrl = hasImage && post.ext !== '.swf' ? getThumbnailUrl(board, post.tim!) : null; const quoteLinks = useMemo(() => extractQuoteLinks(post.com), [post.com]); // Compute backlinks - which posts replied to this post const backlinks = useMemo(() => { return allPosts .filter(p => { if (p.no === post.no) return false; const quotes = extractQuoteLinks(p.com); return quotes.includes(post.no); }) .map(p => p.no); }, [allPosts, post.no]); // Format content with greentext styling const formattedContent = useMemo(() => { if (!post.com) return ''; const sanitized = parsePostContent(post.com); const lines = sanitized.split('
'); return lines .map((line) => { // Greentext if (line.trim().startsWith('>') && !line.trim().startsWith('>>')) { return `${line}`; } // Quote links if (line.includes('>>')) { return line.replace( />>(\d+)/g, '>>$1' ); } return line; }) .join('
'); }, [post.com]); const handleScrollToPost = (postNo: number) => { // Scroll to post const element = document.getElementById(`post-${postNo}`); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); element.classList.add('highlight-post'); setTimeout(() => element.classList.remove('highlight-post'), 2000); } }; const handleQuoteClick = (postNo: number) => { // Find the post and show in popup const quotedPost = allPosts.find(p => p.no === postNo); if (quotedPost) { setPopupPost(quotedPost); } else { // Post not in current thread, just scroll if possible handleScrollToPost(postNo); } }; // Handle escape key to close popup useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && popupPost) { setPopupPost(null); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [popupPost]); return (
{/* Post Header */}
{/* Thumbnail (for OP or if has image) */} {hasImage && (
{(post.ext === '.webm' || post.ext === '.mp4') ? ( // Video handling (webm and mp4) imageExpanded ? ( // Expanded video player - streams directly from CDN
) : ( // Video thumbnail with play overlay ) ) : post.ext === '.swf' ? ( // Flash/SWF handling with Ruffle imageExpanded ? (
) : ( // SWF thumbnail placeholder ) ) : ( // Image handling )} {/* Image info */}
{post.filename && (
{post.filename}{post.ext}
)} {post.fsize && post.w && post.h && (
{formatFileSize(post.fsize)} {formatDimensions(post.w, post.h)}
)}
)} {/* Post info */}
{/* Meta */}
{/* Name */} {post.name || 'Anonymous'} {/* Trip */} {post.trip && ( {post.trip} )} {/* Capcode */} {post.capcode && ( ## {post.capcode} )} {/* ID */} {post.id && ( ID: {post.id} )} {/* Country */} {post.country && ( {post.country} )} {/* Time */} {formatRelativeTime(post.time)} {/* Post number - clickable to show in popup */} setPopupPost(post)} title="Click to preview this post" > No.{post.no} {/* NEW badge for posts since last visit */} {isNew && ( NEW )} {/* Sticky/Closed indicators */} {post.sticky === 1 && ( )} {post.closed === 1 && ( )}
{/* Subject */} {post.sub && (

{post.sub}

)} {/* Comment */} {post.com && (
{ const target = e.target as HTMLElement; if (target.classList.contains('quotelink')) { const postNo = parseInt(target.getAttribute('data-post-no') || '0', 10); handleQuoteClick(postNo); } }} onMouseOver={(e) => { const target = e.target as HTMLElement; if (target.classList.contains('quotelink')) { const postNo = parseInt(target.getAttribute('data-post-no') || '0', 10); if (postNo) setHoveredQuote(postNo); } }} onMouseOut={(e) => { const target = e.target as HTMLElement; if (target.classList.contains('quotelink')) { setHoveredQuote(null); } }} /> )} {/* Quote Hover Preview */} {hoveredQuote && (() => { const quotedPost = allPosts.find(p => p.no === hoveredQuote); if (!quotedPost) return null; return (
{quotedPost.name || 'Anonymous'} {quotedPost.trip && {quotedPost.trip}} No.{quotedPost.no}
{quotedPost.com && (
)}
); })()} {/* OP stats (replies/images) */} {isOP && (post.replies || post.images) && (
{post.replies !== undefined && ( {post.replies} replies )} {post.images !== undefined && ( {post.images} images )} {post.omitted_posts !== undefined && post.omitted_posts > 0 && ( {post.omitted_posts} posts omitted )} {post.omitted_images !== undefined && post.omitted_images > 0 && ( {post.omitted_images} images omitted )}
)} {/* Backlinks - posts that replied to this post */} {backlinks.length > 0 && (
Replies: {backlinks.map(replyNo => ( ))}
)}
{/* Post Preview Popup */} {popupPost && ( setPopupPost(null)} onScrollToPost={handleScrollToPost} /> )}
); }