import { useState, useCallback, useEffect, useRef, type ReactNode, useLayoutEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faRetweet, faHeart, faQuoteLeft, faPenToSquare, faBellSlash, faFlag, } from '@fortawesome/free-solid-svg-icons'; import { faComment as faCommentRegular, faHeart as faHeartRegular, faBellSlash as faBellSlashRegular, } from '@fortawesome/free-regular-svg-icons'; import type { FeedViewPost, PostView, ThreadViewPost, Label } from '../api/types'; import { atprotoClient } from '../api/client'; import { isThreadViewPost, isSuspendedPost, suspendedToThread, } from '../api/types'; import { getCachedThread } from '../prefetch'; import { useAuth } from '../auth/useAuth'; import { usePostViewMode } from '../settings/usePostViewMode'; import { usePostViewer } from './usePostViewer'; import { useProfileViewer } from './useProfileViewer'; import PostCard from './PostCard'; import PostEmbeds from './PostEmbeds'; import LikesModal from './LikesModal'; import RepostsModal from './RepostsModal'; import ComposeModal from './ComposeModal'; import { useReplyStyle } from './useReplyStyle'; import UserLabels from './UserLabels'; import HandleHoverCard from './HandleHoverCard'; import PdsFavicon from './PdsFavicon'; import { buildPostPath, cacheHandleDid, extractRkey } from '../utils/postRoutes'; import { usePostLabels } from '../settings/usePostLabels'; import PostBody from '../utils/renderPostBody'; import ShareButton from './ShareButton'; import ReportModal from './ReportModal'; import './PostThread.css'; function formatFullDate(isoDate: string): string { const d = new Date(isoDate); return d.toLocaleString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true, month: 'short', day: 'numeric', year: 'numeric', }); } function asFeedViewPost(post: PostView): FeedViewPost { return { post }; } function useThreadCache(uri: string) { const cached = getCachedThread(uri); if (cached && isThreadViewPost(cached.thread)) { return { thread: cached.thread, loading: false }; } return { thread: null, loading: true }; } interface PostThreadInlineProps { uri: string; } export default function PostThreadInline({ uri }: PostThreadInlineProps) { const { thread: cachedThread, loading: cachedLoading } = useThreadCache(uri); const [thread, setThread] = useState(cachedThread); const [loading, setLoading] = useState(cachedLoading); const [error, setError] = useState(null); const [showQuote, setShowQuote] = useState(false); const [showLikes, setShowLikes] = useState(false); const [showReposts, setShowReposts] = useState(false); const [showReply, setShowReply] = useState(false); const [showRepostMenu, setShowRepostMenu] = useState(false); const [showEdit, setShowEdit] = useState(false); const [showReport, setShowReport] = useState(false); const [likeUri, setLikeUri] = useState(thread?.post.viewer?.like ?? null); const [likeCount, setLikeCount] = useState(thread?.post.likeCount ?? 0); const [repostUri, setRepostUri] = useState(thread?.post.viewer?.repost ?? null); const [repostCount, setRepostCount] = useState(thread?.post.repostCount ?? 0); const [replyCount, setReplyCount] = useState(thread?.post.replyCount ?? 0); const [liking, setLiking] = useState(false); const [reposting, setReposting] = useState(false); const [threadMuted, setThreadMuted] = useState(false); const [mutingThread, setMutingThread] = useState(false); const mainPostRef = useRef(null); useLayoutEffect(() => { if (!loading && thread && mainPostRef.current) { const element = mainPostRef.current; requestAnimationFrame(() => { element.scrollIntoView({ block: 'center', behavior: 'instant' }); }); } }, [loading, !!thread]); useEffect(() => { const handler = (e: Event) => { const { type, replyToUri } = (e as CustomEvent).detail ?? {}; if (type === 'reply' && replyToUri === uri) { setReplyCount((c) => c + 1); } }; window.addEventListener('foxsky:post-created', handler); return () => window.removeEventListener('foxsky:post-created', handler); }, [uri]); const { open: openPost } = usePostViewer(); const { open: openProfile } = useProfileViewer(); const { mode: viewMode } = usePostViewMode(); const { style: replyStyle } = useReplyStyle(); const { session } = useAuth(); const { t: label } = usePostLabels(); useEffect(() => { if (!thread) return; const cacheAuthor = (author: { handle: string; did: string }) => { cacheHandleDid(author.handle, author.did); }; cacheAuthor(thread.post.author); const walk = (node: ThreadViewPost | unknown | undefined) => { if (!node || !isThreadViewPost(node)) return; cacheAuthor(node.post.author); if (node.replies) node.replies.forEach(walk); if (node.parent) walk(node.parent); }; walk(thread.parent); if (thread.replies) thread.replies.forEach(walk); }, [thread]); const refreshThread = useCallback(async () => { if (!uri) return; try { const resp = await atprotoClient.getPostThread(uri); const threadNode = isSuspendedPost(resp.thread) ? suspendedToThread(resp.thread) : resp.thread; if (isThreadViewPost(threadNode)) { setThread(threadNode); setLikeUri(threadNode.post.viewer?.like ?? null); setLikeCount(threadNode.post.likeCount ?? 0); setRepostUri(threadNode.post.viewer?.repost ?? null); setRepostCount(threadNode.post.repostCount ?? 0); setReplyCount(threadNode.post.replyCount ?? 0); setThreadMuted(threadNode.post.viewer?.threadMuted ?? false); setError(null); } else { setError('Post not found or blocked.'); } } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to load thread'; setError(msg); } finally { setLoading(false); } }, [uri]); useEffect(() => { if (!uri) return; if (cachedThread) return; let cancelled = false; (async () => { try { const resp = await atprotoClient.getPostThread(uri); if (!cancelled) { const threadNode = isSuspendedPost(resp.thread) ? suspendedToThread(resp.thread) : resp.thread; if (isThreadViewPost(threadNode)) { setThread(threadNode); setLikeUri(threadNode.post.viewer?.like ?? null); setLikeCount(threadNode.post.likeCount ?? 0); setRepostUri(threadNode.post.viewer?.repost ?? null); setRepostCount(threadNode.post.repostCount ?? 0); setReplyCount(threadNode.post.replyCount ?? 0); setThreadMuted(threadNode.post.viewer?.threadMuted ?? false); } else { setError('Post not found or blocked.'); } } } catch (err) { if (!cancelled) { const msg = err instanceof Error ? err.message : 'Failed to load thread'; setError(msg); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [uri, cachedThread]); const navigate = useNavigate(); const handleOpenPost = useCallback((postUri: string, authorHandle?: string, authorDid?: string) => { const postRoute = buildPostPath(postUri, authorHandle, authorDid); navigate(postRoute); openPost(postUri, viewMode); }, [navigate, openPost, viewMode]); const handleOpenProfile = useCallback((actor: string) => { const profileRoute = `/profile/${encodeURIComponent(actor)}`; navigate(profileRoute); openProfile(actor); }, [navigate, openProfile]); const handlePostAuxClick = useCallback((postUri: string, authorHandle?: string, authorDid?: string) => { const route = buildPostPath(postUri, authorHandle, authorDid); window.open(route, '_blank'); }, []); const handleProfileAuxClick = useCallback((actor: string) => { const route = `/profile/${encodeURIComponent(actor)}`; window.open(route, '_blank'); }, []); const handleLike = useCallback(async () => { if (!thread || 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(thread.post.uri, thread.post.cid); setLikeUri(result.uri); setLikeCount((c) => c + 1); } } catch (err) { console.error('Like failed:', err); } finally { setLiking(false); } }, [thread, liking, likeUri]); const handleRepost = useCallback(async () => { if (!thread || 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(thread.post.uri, thread.post.cid); setRepostUri(result.uri); setRepostCount((c) => c + 1); } } catch (err) { console.error('Repost failed:', err); } finally { setReposting(false); } }, [thread, reposting, repostUri]); useEffect(() => { if (thread) { setThreadMuted(thread.post.viewer?.threadMuted ?? false); } }, [thread]); const handleMuteThread = useCallback(async () => { if (!thread || mutingThread) return; setMutingThread(true); try { if (threadMuted) { await atprotoClient.unmuteThread(thread.post.uri); setThreadMuted(false); } else { await atprotoClient.muteThread(thread.post.uri); setThreadMuted(true); } } catch (err) { console.error('Mute thread failed:', err); } finally { setMutingThread(false); } }, [thread, mutingThread, threadMuted]); function renderParents(parent: ThreadViewPost | unknown | undefined): ReactNode { if (!parent || !isThreadViewPost(parent)) return null; const nodes: ThreadViewPost[] = []; const seenUris = new Set(); let current: ThreadViewPost | undefined = parent; while (current) { if (!seenUris.has(current.post.uri)) { seenUris.add(current.post.uri); nodes.unshift(current); } current = current.parent && isThreadViewPost(current.parent) ? current.parent : undefined; } return nodes.map((node) => (
handleOpenPost(node.post.uri, node.post.author.handle, node.post.author.did)} onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); handlePostAuxClick(node.post.uri, node.post.author.handle, node.post.author.did); } }}>
)); } function renderReplies(replies: (ThreadViewPost | unknown)[] | undefined, depth = 0): ReactNode { if (!replies || replies.length === 0) return null; const seenUris = new Set(); if (replyStyle === 'flat') { const allReplies: ReactNode[] = []; function flatten(repls: (ThreadViewPost | unknown)[]) { for (let i = 0; i < repls.length; i++) { const reply = repls[i]; if (!isThreadViewPost(reply)) { allReplies.push(
Post not available
); } else { const key = seenUris.has(reply.post.uri) ? `${reply.post.uri}-${allReplies.length}` : reply.post.uri; seenUris.add(reply.post.uri); allReplies.push(
handleOpenPost(reply.post.uri, reply.post.author.handle, reply.post.author.did)} onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); handlePostAuxClick(reply.post.uri, reply.post.author.handle, reply.post.author.did); } }}>
); if (reply.replies && reply.replies.length > 0) { flatten(reply.replies); } } } } flatten(replies); return
{allReplies}
; } return (
{replies.map((reply, i) => { if (!isThreadViewPost(reply)) { return (
Post not available
); } const key = seenUris.has(reply.post.uri) ? `${reply.post.uri}-dup-${depth}-${i}` : reply.post.uri; seenUris.add(reply.post.uri); return (
handleOpenPost(reply.post.uri, reply.post.author.handle, reply.post.author.did)} onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); handlePostAuxClick(reply.post.uri, reply.post.author.handle, reply.post.author.did); } }}>
{reply.replies && reply.replies.length > 0 && renderReplies(reply.replies, depth + 1)}
); })}
); } if (loading) { return
Loading thread…
; } if (error || !thread) { return
{error ?? 'Post not found'}
; } const post = thread.post; const displayName = post.author.displayName ?? post.author.handle; const pronouns = post.author.pronouns; return (
{renderParents(thread.parent)}
handleOpenPost(embedUri, embedHandle, embedDid)} />
{formatFullDate(post.indexedAt)}
{typeof repostCount === 'number' && ( )} {typeof likeCount === 'number' && ( )} {typeof post.quoteCount === 'number' && post.quoteCount > 0 && ( {post.quoteCount} quotes )}
{showRepostMenu && (
e.stopPropagation()}>
)} {(() => { const rkey = extractRkey(post.uri); if (!rkey) return null; const postRoute = buildPostPath(post.uri, post.author.handle, post.author.did); return ( ); })()} {session && session.did === post.author.did && ( )}
{showQuote && thread && ( setShowQuote(false)} onPosted={refreshThread} /> )} {renderReplies(thread.replies)} {(!thread.replies || thread.replies.length === 0) && (
No {label('reply', true)} yet
)} {showEdit && ( setShowEdit(false)} onPosted={refreshThread} /> )} {showReply && ( setShowReply(false)} onPosted={refreshThread} /> )} {showLikes && ( setShowLikes(false)} /> )} {showReposts && ( setShowReposts(false)} /> )} {showReport && ( setShowReport(false)} /> )}
); }