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}
/>
)}
);
}