import { useState, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faRetweet, faHeart, faQuoteLeft, faPenToSquare, faBookmark as faBookmarkSolid, faFlag, faEllipsisVertical, } from '@fortawesome/free-solid-svg-icons'; import { faComment as faCommentRegular, faHeart as faHeartRegular, faBookmark as faBookmarkRegular, } from '@fortawesome/free-regular-svg-icons'; import type { FeedViewPost, Label } from '../api/types'; import { atprotoClient } from '../api/client'; import { prefetchThread } from '../prefetch'; import { useAuth } from '../auth/useAuth'; import { usePostViewMode } from '../settings/usePostViewMode'; import { useProfileViewMode } from '../settings/useProfileViewMode'; import { useProfileViewer } from './useProfileViewer'; import { usePostViewer } from './usePostViewer'; import { useNavigationMemory } from './useNavigationMemory'; import PostEmbeds from './PostEmbeds'; import ComposeModal from './ComposeModal'; import FollowButton from './FollowButton'; import UserLabels from './UserLabels'; import HandleHoverCard from './HandleHoverCard'; import PdsFavicon from './PdsFavicon'; import PostBody from '../utils/renderPostBody'; import { useBookmarks } from './bookmarkContext'; import { useMuteBlock } from './muteBlockContext'; import { buildPostPath, extractRkey } from '../utils/postRoutes'; import { useImageProxy, useProxiedUrl } from '../settings/ImageProxyProvider'; import { usePostLabels } from '../settings/usePostLabels'; import ShareButton from './ShareButton'; import ReportModal from './ReportModal'; import './PostCard.css'; interface PostCardProps { item: FeedViewPost; inThread?: boolean; onAction?: () => void; } function relativeTime(isoDate: string): string { const diff = Date.now() - new Date(isoDate).getTime(); const seconds = Math.floor(diff / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); if (days < 365) return `${days}d`; return `${Math.floor(days / 365)}y`; } function AvatarImage({ src, className, alt }: { src: string; className: string; alt?: string }) { const { markActiveProxyFailed } = useImageProxy(); const proxied = useProxiedUrl(src); return ( {alt markActiveProxyFailed()} /> ); } export default function PostCard({ item, inThread, onAction }: PostCardProps) { const { post, reason } = item; const navigate = useNavigate(); const { mode: viewMode } = usePostViewMode(); const { mode: profileViewMode } = useProfileViewMode(); const { open: openPost } = usePostViewer(); const { open: openProfile } = useProfileViewer(); const { session } = useAuth(); const { isBookmarked, addBookmark, removeBookmark } = useBookmarks(); const { isMuted, isBlocked, isBlockedBy, addBlockedBy, ignoreBlocks } = useMuteBlock(); const { clearScrollPosition } = useNavigationMemory(); const { t: label } = usePostLabels(); const [likeUri, setLikeUri] = useState(post.viewer?.like ?? null); const [likeCount, setLikeCount] = useState(post.likeCount ?? 0); const [repostUri, setRepostUri] = useState(post.viewer?.repost ?? null); const [repostCount, setRepostCount] = useState(post.repostCount ?? 0); const [replyCount, setReplyCount] = useState(post.replyCount ?? 0); const prevLikeUriRef = useRef(post.viewer?.like); const prevLikeCountRef = useRef(post.likeCount); const prevRepostUriRef = useRef(post.viewer?.repost); const prevRepostCountRef = useRef(post.repostCount); const prevReplyCountRef = useRef(post.replyCount); if ( post.viewer?.like !== prevLikeUriRef.current || post.likeCount !== prevLikeCountRef.current || post.viewer?.repost !== prevRepostUriRef.current || post.repostCount !== prevRepostCountRef.current || post.replyCount !== prevReplyCountRef.current ) { prevLikeUriRef.current = post.viewer?.like; prevLikeCountRef.current = post.likeCount; prevRepostUriRef.current = post.viewer?.repost; prevRepostCountRef.current = post.repostCount; prevReplyCountRef.current = post.replyCount; setLikeUri(post.viewer?.like ?? null); setLikeCount(post.likeCount ?? 0); setRepostUri(post.viewer?.repost ?? null); setRepostCount(post.repostCount ?? 0); setReplyCount(post.replyCount ?? 0); } const [liking, setLiking] = useState(false); const [reposting, setReposting] = useState(false); const [showReply, setShowReply] = useState(false); const [showRepostMenu, setShowRepostMenu] = useState(false); const [showQuote, setShowQuote] = useState(false); const [showEdit, setShowEdit] = useState(false); const [showReport, setShowReport] = useState(false); const [showMoreMenu, setShowMoreMenu] = useState(false); const [bookmarked, setBookmarked] = useState(() => isBookmarked(post.uri)); const [bookmarking, setBookmarking] = useState(false); const [threadMuted, setThreadMuted] = useState(post.viewer?.threadMuted ?? false); const [mutingThread, setMutingThread] = useState(false); const [forceShowHidden, setForceShowHidden] = useState(false); const moreMenuRef = useRef(null); // Close more menu on outside click useEffect(() => { if (!showMoreMenu) return; const handleClick = (e: MouseEvent) => { if (moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) { setShowMoreMenu(false); } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [showMoreMenu]); // Close more menu on Escape useEffect(() => { if (!showMoreMenu) return; const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowMoreMenu(false); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [showMoreMenu]); const displayName = post.author.displayName ?? post.author.handle ?? ''; const pronouns = post.author.pronouns; const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'; const postRoute = buildPostPath(post.uri, post.author.handle, post.author.did); const openPostByMode = useCallback((e?: React.MouseEvent) => { if (e) { const target = e.target as HTMLElement; if ( target.closest('a:not(.post-card-link-overlay)') || target.closest('button') || target.closest('video') || target.closest('textarea') || target.closest('input') ) { return; } } switch (viewMode) { case 'page': clearScrollPosition(postRoute); navigate(postRoute); break; case 'modal': case 'side': clearScrollPosition(postRoute); navigate(postRoute); openPost(post.uri, viewMode); break; } }, [navigate, postRoute, viewMode, openPost, post.uri]); const openProfileByMode = useCallback((actor: string) => { const profileRoute = `/profile/${encodeURIComponent(actor)}`; switch (profileViewMode) { case 'page': navigate(profileRoute); break; case 'side': navigate(profileRoute); openProfile(actor); break; } }, [navigate, profileViewMode, openProfile]); const handleProfileAuxClick = useCallback((e: React.MouseEvent) => { if (e.button !== 1) return; e.stopPropagation(); e.preventDefault(); window.open(`/profile/${encodeURIComponent(post.author.handle)}`, '_blank'); }, [post.author.handle]); const handleLike = useCallback(async () => { if (liking) return; setLiking(true); try { if (likeUri) { await atprotoClient.unlike(likeUri); setLikeUri(null); setLikeCount((c) => Math.max(0, c - 1)); } else { const result = await atprotoClient.like(post.uri, post.cid); setLikeUri(result.uri); setLikeCount((c) => c + 1); } onAction?.(); } catch (err) { console.error('Like failed:', err); } finally { setLiking(false); } }, [liking, likeUri, post.uri, post.cid, onAction]); const handleRepost = useCallback(async () => { if (reposting) return; setReposting(true); try { if (repostUri) { await atprotoClient.unrepost(repostUri); setRepostUri(null); setRepostCount((c) => Math.max(0, c - 1)); } else { const result = await atprotoClient.repost(post.uri, post.cid); setRepostUri(result.uri); setRepostCount((c) => c + 1); } onAction?.(); } catch (err) { console.error('Repost failed:', err); } finally { setReposting(false); } }, [reposting, repostUri, post.uri, post.cid, onAction]); const handleBookmark = useCallback(async () => { if (bookmarking) return; setBookmarking(true); try { if (bookmarked) { await removeBookmark(post.uri); setBookmarked(false); } else { await addBookmark(post.uri, post.cid); setBookmarked(true); } } catch (err) { console.error('Bookmark failed:', err); } finally { setBookmarking(false); } }, [bookmarking, bookmarked, post.uri, post.cid, addBookmark, removeBookmark]); const handleMuteThread = useCallback(async () => { if (mutingThread) return; setMutingThread(true); try { if (threadMuted) { await atprotoClient.unmuteThread(post.uri); setThreadMuted(false); } else { await atprotoClient.muteThread(post.uri); setThreadMuted(true); } onAction?.(); } catch (err) { console.error('Mute thread failed:', err); } finally { setMutingThread(false); } }, [mutingThread, threadMuted, post.uri, onAction]); useEffect(() => { setBookmarked(isBookmarked(post.uri)); }, [isBookmarked, post.uri]); // Listen for reply events useEffect(() => { const handler = (e: Event) => { const { type, replyToUri } = (e as CustomEvent).detail ?? {}; if (type === 'reply' && replyToUri === post.uri) { setReplyCount((c) => c + 1); } }; window.addEventListener('foxsky:post-created', handler); return () => window.removeEventListener('foxsky:post-created', handler); }, [post.uri]); const clickHandler = inThread ? undefined : openPostByMode; // Register blockedBy signal from profile data embedded in the post if (post.author.viewer?.blockedBy) addBlockedBy(post.author.did); const authorMuted = !ignoreBlocks && isMuted(post.author.did); const authorBlocked = !ignoreBlocks && isBlocked(post.author.did); if ((authorBlocked || authorMuted) && !forceShowHidden) { return (
{authorBlocked ? 'Blocked' : 'Muted'} {label('post')} from {displayName} (@{post.author.handle})
); } const handleMouseEnter = useCallback(() => { if (!inThread) { prefetchThread(post.uri); } }, [inThread, post.uri]); const handlePostClick = useCallback((e: React.MouseEvent) => { if (showRepostMenu) { setShowRepostMenu(false); return; } if (showMoreMenu) { setShowMoreMenu(false); return; } clickHandler?.(e); }, [showRepostMenu, showMoreMenu, clickHandler]); const handlePostAuxClick = useCallback((e: React.MouseEvent) => { if (e.button !== 1) return; const target = e.target as HTMLElement; if ( target.closest('a:not(.post-card-link-overlay)') || target.closest('button') || target.closest('video') || target.closest('textarea') || target.closest('input') ) { return; } e.preventDefault(); window.open(postRoute, '_blank'); }, [postRoute]); return (
{!inThread && ( { e.preventDefault(); e.stopPropagation(); switch (viewMode) { case 'page': navigate(replyRoute); break; case 'modal': case 'side': navigate(replyRoute); openPost(rootUri, viewMode); break; } }} >
{rootAuthor.avatar ? ( ) : (
{((rootAuthor.displayName ?? rootAuthor.handle) || '?').charAt(0).toUpperCase()}
)} {label('reply', false, true)} to{' '} {rootAuthor.displayName ?? rootAuthor.handle} {isBlocked(rootAuthorDid) && Blocked} {isMuted(rootAuthorDid) && Muted} {rootAuthor.suspended && Suspended} {rootText && ( {' '}ยท {rootText.length > 80 ? rootText.slice(0, 80) + '\u2026' : rootText} )}
); })()}
{post.author.viewer?.followedBy && Follows you} {isBlockedBy(post.author.did) && Blocks you} {isBlocked(post.author.did) && Blocked} {isMuted(post.author.did) && Muted} {post.author.suspended && Suspended}
{session && session.did !== post.author.did && ( )} {relativeTime(post.indexedAt)}
{ const route = buildPostPath(embedUri, embedHandle, embedDid); switch (viewMode) { case 'page': navigate(route); break; case 'modal': case 'side': navigate(route); openPost(embedUri, viewMode); break; } }} />
{showRepostMenu && (
e.stopPropagation()}>
)} {(() => { const rkey = extractRkey(post.uri); if (!rkey) return null; return ( ); })()} {session && session.did === post.author.did && ( )}
{showMoreMenu && (
e.stopPropagation()}>
)}
{showReply && ( setShowReply(false)} onPosted={onAction ?? (() => {})} /> )} {showQuote && ( setShowQuote(false)} onPosted={onAction ?? (() => {})} /> )} {showEdit && ( setShowEdit(false)} onPosted={onAction ?? (() => {})} /> )} {showReport && ( setShowReport(false)} /> )}
); }