import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import {useParams, useNavigate, useLocation} from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowLeft, 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, PostView, ThreadViewPost, NotFoundPost, BlockedPost, Label } from '../api/types'; import { isThreadViewPost, isBlockedPost, isNotFoundPost, isSuspendedPost, suspendedToThread, } from '../api/types'; import { atprotoClient } from '../api/client'; import { getCachedThread } from '../prefetch'; import { useAuth } from '../auth/useAuth'; import { usePostViewMode } from '../settings/usePostViewMode'; import { useProfileViewMode } from '../settings/useProfileViewMode'; 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 { useBookmarks } from './bookmarkContext'; import { useMuteBlock } from './muteBlockContext'; import UserLabels from './UserLabels'; import HandleHoverCard from './HandleHoverCard'; import PdsFavicon from './PdsFavicon'; import { buildPostPath, buildAtUriFromRoute, 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'; import {useNavigationMemory} from "./useNavigationMemory.ts"; 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) { if (isThreadViewPost(cached.thread)) { return { thread: cached.thread, loading: false }; } // Handle suspended posts from cache if (isSuspendedPost(cached.thread)) { return { thread: suspendedToThread(cached.thread), loading: false }; } } return { thread: null, loading: true }; } export default function PostThread() { const params = useParams<{ uri?: string; handle?: string; rkey?: string }>(); const navigate = useNavigate(); const legacyUri = params.uri ? decodeURIComponent(params.uri) : null; const [resolvedNewUri, setResolvedNewUri] = useState(null); const [resolveError, setResolveError] = useState(null); useEffect(() => { if (!params.handle || !params.rkey) return; let cancelled = false; const handle = decodeURIComponent(params.handle); const rkey = params.rkey; setResolvedNewUri(null); setResolveError(null); buildAtUriFromRoute(handle, rkey) .then((uri) => { if (!cancelled) { setResolvedNewUri(uri); setResolveError(null); } }) .catch((err) => { if (!cancelled) { setResolveError(err instanceof Error ? err.message : 'Failed to resolve handle'); } }); return () => { cancelled = true; }; }, [params.handle, params.rkey]); const uri = legacyUri ?? resolvedNewUri ?? ''; const { thread: cachedThread, loading: cachedLoading } = useThreadCache(uri); const { mode: viewMode } = usePostViewMode(); const { mode: profileViewMode } = useProfileViewMode(); const { open: openPost } = usePostViewer(); const { open: openProfile } = useProfileViewer(); const { pathname, search } = useLocation(); const { session } = useAuth(); const { isBookmarked, addBookmark, removeBookmark } = useBookmarks(); const { isMuted, isBlocked, isBlockedBy, addBlockedBy, ignoreBlocks } = useMuteBlock(); const { pageStates, setPageState, setScrollPosition } = useNavigationMemory(); const { mode: postViewMode } = usePostViewMode(); const { t: label } = usePostLabels(); const [thread, setThread] = useState(() => { if (postViewMode !== 'page') return cachedThread; const saved = pageStates[pathname + search]; return saved?.thread || cachedThread; }); const [loading, setLoading] = useState(() => { if (postViewMode !== 'page') return cachedLoading; const saved = pageStates[pathname + search]; return saved?.thread ? false : cachedLoading; }); const [error, setError] = useState(null); useEffect(() => { if (!uri) return; setThread(null); setLoading(true); setError(null); if (cachedThread) { setThread(cachedThread); setLikeUri(cachedThread.post.viewer?.like ?? null); setLikeCount(cachedThread.post.likeCount ?? 0); setRepostUri(cachedThread.post.viewer?.repost ?? null); setRepostCount(cachedThread.post.repostCount ?? 0); setReplyCount(cachedThread.post.replyCount ?? 0); setThreadMuted(cachedThread.post.viewer?.threadMuted ?? false); setLoading(false); return; } if (postViewMode === 'page') { const saved = pageStates[pathname + search]; if (saved?.thread) { setThread(saved.thread); setLikeUri(saved.likeUri ?? null); setLikeCount(saved.likeCount ?? 0); setRepostUri(saved.repostUri ?? null); setRepostCount(saved.repostCount ?? 0); setReplyCount(saved.replyCount ?? 0); setLoading(false); return; } } let cancelled = false; (async () => { try { const resp = await atprotoClient.getPostThread(uri); if (!cancelled) { // Handle SuspendedPost — wrap it as a ThreadViewPost 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, pathname, search, postViewMode, ignoreBlocks]); const [showQuote, setShowQuote] = useState(false); const [showLikes, setShowLikes] = useState(false); const [showReposts, setShowReposts] = useState(false); const { scrollPositions } = useNavigationMemory(); const mainPostRef = useRef(null); // Restore scroll position useLayoutEffect(() => { if (postViewMode !== 'page') return; if (!loading && thread) { const savedPos = scrollPositions[pathname + search]; if (savedPos !== undefined && savedPos > 1) { window.scrollTo(0, savedPos); } else if (mainPostRef.current) { requestAnimationFrame(() => { if (!mainPostRef.current) return; const headerElement = document.querySelector('.thread-header'); const headerHeight = headerElement ? headerElement.getBoundingClientRect().height : 0; const rect = mainPostRef.current.getBoundingClientRect(); const elementTop = rect.top + window.pageYOffset; const elementHeight = rect.height; const viewportHeight = window.innerHeight; const visibleViewportHeight = viewportHeight - headerHeight; const offsetPosition = elementTop - headerHeight - (visibleViewportHeight / 2) + (elementHeight / 2); window.scrollTo({ top: Math.max(0, offsetPosition), behavior: 'instant' }); }); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, !!thread, pathname, search, postViewMode, mainPostRef.current]); // Save scroll position useEffect(() => { if (postViewMode !== 'page') return; const handleScroll = () => { setScrollPosition(pathname + search, window.scrollY); }; window.addEventListener('scroll', handleScroll, { passive: true }); return () => window.removeEventListener('scroll', handleScroll); }, [pathname, search, setScrollPosition, postViewMode]); const [showReply, setShowReply] = useState(false); const [showRepostMenu, setShowRepostMenu] = useState(false); const [showEdit, setShowEdit] = useState(false); const [showReport, setShowReport] = useState(false); const [showMoreMenu, setShowMoreMenu] = useState(false); const moreMenuRef = useRef(null); 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]); 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 [likeUri, setLikeUri] = useState(null); const [likeCount, setLikeCount] = useState(0); const [repostUri, setRepostUri] = useState(null); const [repostCount, setRepostCount] = useState(0); const [replyCount, setReplyCount] = useState(0); useEffect(() => { if (postViewMode !== 'page') return; setPageState(pathname + search, (prev: any) => ({ ...prev, thread, likeUri, likeCount, repostUri, repostCount, replyCount, })); }, [thread, likeUri, likeCount, repostUri, repostCount, replyCount, pathname, search, setPageState, postViewMode]); const [liking, setLiking] = useState(false); const [reposting, setReposting] = useState(false); const [bookmarked, setBookmarked] = useState(false); const [bookmarking, setBookmarking] = useState(false); const [threadMuted, setThreadMuted] = useState(false); const [mutingThread, setMutingThread] = useState(false); useEffect(() => { if (!thread) return; const cacheAuthor = (author: { handle: string; did: string }) => { cacheHandleDid(author.handle, author.did); }; cacheAuthor(thread.post.author); const walk = (node: ThreadViewPost | NotFoundPost | BlockedPost | { $type: string } | 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]); 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 refreshThread = useCallback(async () => { if (!uri) return; try { const resp = await atprotoClient.getPostThread(uri); // Handle SuspendedPost 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 (thread) { setBookmarked(isBookmarked(thread.post.uri)); } }, [thread, isBookmarked]); const handleBookmark = useCallback(async () => { if (!thread || bookmarking) return; setBookmarking(true); try { if (bookmarked) { await removeBookmark(thread.post.uri); setBookmarked(false); } else { await addBookmark(thread.post.uri, thread.post.cid); setBookmarked(true); } } catch (err) { console.error('Bookmark failed:', err); } finally { setBookmarking(false); } }, [thread, bookmarking, bookmarked, addBookmark, removeBookmark]); 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]); const { style: replyStyle } = useReplyStyle(); const handleOpenPost = useCallback((postUri: string, authorHandle?: string, authorDid?: string) => { const postRoute = buildPostPath(postUri, authorHandle, authorDid); switch (viewMode) { case 'page': navigate(postRoute); break; case 'modal': case 'side': navigate(postRoute); openPost(postUri, viewMode); break; } }, [navigate, viewMode, openPost]); const handleOpenProfile = 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((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]); function renderParents(parent: ThreadViewPost | NotFoundPost | BlockedPost | { $type: string } | undefined): React.ReactNode { if (!parent) return null; if (isBlockedPost(parent)) { return (
Post by blocked user
); } if (isNotFoundPost(parent)) { return (
Post deleted
); } if (!isThreadViewPost(parent)) return null; type AncestorNode = { type: 'post'; data: ThreadViewPost } | { type: 'blocked' } | { type: 'deleted' }; const nodes: AncestorNode[] = []; const seenUris = new Set(); let current: ThreadViewPost | undefined = parent; while (current) { if (!seenUris.has(current.post.uri)) { seenUris.add(current.post.uri); nodes.push({ type: 'post', data: current }); } // @ts-ignore const rawParent = current.parent; if (rawParent && !isThreadViewPost(rawParent)) { if (isBlockedPost(rawParent)) { nodes.unshift({ type: 'blocked' }); } else if (isNotFoundPost(rawParent)) { nodes.unshift({ type: 'deleted' }); } break; } current = rawParent && isThreadViewPost(rawParent) ? rawParent : undefined; } return nodes.map((node, idx) => { if (node.type === 'blocked') { return (
Post by blocked user
); } if (node.type === 'deleted') { return (
Post deleted
); } const postNode = node.data; const parentRoute = buildPostPath(postNode.post.uri, postNode.post.author.handle, postNode.post.author.did); return ( ); }); } function renderReplies(replies: (ThreadViewPost | NotFoundPost | BlockedPost | { $type: string })[] | undefined, depth = 0): React.ReactNode { if (!replies || replies.length === 0) return null; const filteredReplies = replies.filter((reply) => { if (!isThreadViewPost(reply)) return true; // Keep not-found/blocked markers if (ignoreBlocks) return true; return !isMuted(reply.post.author.did) && !isBlocked(reply.post.author.did); }); const seenUris = new Set(); if (replyStyle === 'flat') { const allReplies: React.ReactNode[] = []; function flatten(repls: (ThreadViewPost | NotFoundPost | BlockedPost | { $type: string })[]) { for (let i = 0; i < repls.length; i++) { const reply = repls[i]; if (isBlockedPost(reply)) { allReplies.push(
Post by blocked user
); } else if (isNotFoundPost(reply)) { allReplies.push(
Post deleted
); } else 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); const replyRoute = buildPostPath(reply.post.uri, reply.post.author.handle, reply.post.author.did); allReplies.push( ); if (reply.replies && reply.replies.length > 0) { flatten(reply.replies); } } } } flatten(filteredReplies); return
{allReplies}
; } return (
{filteredReplies.map((reply, i) => { if (isBlockedPost(reply)) { return (
Post by blocked user
); } if (isNotFoundPost(reply)) { return (
Post deleted
); } 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); const nestedRoute = buildPostPath(reply.post.uri, reply.post.author.handle, reply.post.author.did); return ( ); })}
); } if (!legacyUri && !resolvedNewUri && !resolveError) { return (
Resolving post…
); } if (resolveError) { return (
{resolveError}
); } 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; // Register blockedBy signal from profile data embedded in the post if (post.author.viewer?.blockedBy) addBlockedBy(post.author.did); return (
{label('post', false, true)}
{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 && ( )}
{showMoreMenu && (
e.stopPropagation()}>
)}
{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)} /> )}
); }