Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
36 kB · 989 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990import { 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<string | null>(null); const [resolveError, setResolveError] = useState<string | null>(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<ThreadViewPost | null>(() => { 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<string | null>(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<HTMLDivElement>(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<HTMLDivElement>(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<string | null>(null); const [likeCount, setLikeCount] = useState(0); const [repostUri, setRepostUri] = useState<string | null>(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 ( <div className="thread-ancestor"> <div className="thread-reply-unavailable thread-reply-unavailable--blocked"> Post by blocked user </div> </div> ); } if (isNotFoundPost(parent)) { return ( <div className="thread-ancestor"> <div className="thread-reply-unavailable thread-reply-unavailable--deleted"> Post deleted </div> </div> ); } if (!isThreadViewPost(parent)) return null; type AncestorNode = { type: 'post'; data: ThreadViewPost } | { type: 'blocked' } | { type: 'deleted' }; const nodes: AncestorNode[] = []; const seenUris = new Set<string>(); 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 ( <div key={`parent-blocked-${idx}`} className="thread-ancestor"> <div className="thread-reply-unavailable thread-reply-unavailable--blocked"> Post by blocked user </div> </div> ); } if (node.type === 'deleted') { return ( <div key={`parent-deleted-${idx}`} className="thread-ancestor"> <div className="thread-reply-unavailable thread-reply-unavailable--deleted"> Post deleted </div> </div> ); } const postNode = node.data; const parentRoute = buildPostPath(postNode.post.uri, postNode.post.author.handle, postNode.post.author.did); return ( <div key={postNode.post.uri} className="thread-ancestor"> <a href={parentRoute} className="thread-reply-clickable" onClick={(e) => { e.preventDefault(); handleOpenPost(postNode.post.uri, postNode.post.author.handle, postNode.post.author.did); }} onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); window.open(parentRoute, '_blank'); } }} > <PostCard item={asFeedViewPost(postNode.post)} inThread onAction={refreshThread} /> </a> </div> ); }); }
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<string>();
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( <div key={`blocked-${allReplies.length}`} className="thread-reply-unavailable thread-reply-unavailable--blocked"> Post by blocked user </div> ); } else if (isNotFoundPost(reply)) { allReplies.push( <div key={`notfound-${allReplies.length}`} className="thread-reply-unavailable thread-reply-unavailable--deleted"> Post deleted </div> ); } else if (!isThreadViewPost(reply)) { allReplies.push( <div key={`notfound-${allReplies.length}`} className="thread-reply-unavailable"> Post not available </div> ); } 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( <div key={key} className="thread-reply"> <a href={replyRoute} className="thread-reply-clickable" onClick={(e) => { e.preventDefault(); handleOpenPost(reply.post.uri, reply.post.author.handle, reply.post.author.did); }} onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); window.open(replyRoute, '_blank'); } }} > <PostCard item={asFeedViewPost(reply.post)} inThread onAction={refreshThread} /> </a> </div> ); if (reply.replies && reply.replies.length > 0) { flatten(reply.replies); } } } } flatten(filteredReplies); return <div className="thread-replies thread-replies--flat">{allReplies}</div>; }
return ( <div className={`thread-replies thread-replies--depth-${Math.min(depth, 3)}`}> {filteredReplies.map((reply, i) => { if (isBlockedPost(reply)) { return ( <div key={`blocked-${i}`} className="thread-reply-unavailable thread-reply-unavailable--blocked"> Post by blocked user </div> ); } if (isNotFoundPost(reply)) { return ( <div key={`notfound-${i}`} className="thread-reply-unavailable thread-reply-unavailable--deleted"> Post deleted </div> ); } if (!isThreadViewPost(reply)) { return ( <div key={`notfound-${i}`} className="thread-reply-unavailable"> Post not available </div> ); } 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 ( <div key={key} className="thread-reply"> <a href={nestedRoute} className="thread-reply-clickable" onClick={(e) => { e.preventDefault(); handleOpenPost(reply.post.uri, reply.post.author.handle, reply.post.author.did); }} onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); window.open(nestedRoute, '_blank'); } }} > <PostCard item={asFeedViewPost(reply.post)} inThread onAction={refreshThread} /> </a> {reply.replies && reply.replies.length > 0 && renderReplies(reply.replies, depth + 1)} </div> ); })} </div> ); }
if (!legacyUri && !resolvedNewUri && !resolveError) { return ( <div className="thread-page"> <div className="thread-header"> <button className="thread-back" onClick={() => navigate(-1)}><FontAwesomeIcon icon={faArrowLeft} /> Back</button> </div> <div className="thread-loading">Resolving post…</div> </div> ); }
if (resolveError) { return ( <div className="thread-page"> <div className="thread-header"> <button className="thread-back" onClick={() => navigate(-1)}><FontAwesomeIcon icon={faArrowLeft} /> Back</button> </div> <div className="thread-error">{resolveError}</div> </div> ); }
if (loading) { return ( <div className="thread-page"> <div className="thread-header"> <button className="thread-back" onClick={() => navigate(-1)}><FontAwesomeIcon icon={faArrowLeft} /> Back</button> </div> <div className="thread-loading">Loading thread…</div> </div> ); }
if (error || !thread) { return ( <div className="thread-page"> <div className="thread-header"> <button className="thread-back" onClick={() => navigate(-1)}><FontAwesomeIcon icon={faArrowLeft} /> Back</button> </div> <div className="thread-error">{error ?? 'Post not found'}</div> </div> ); }
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 ( <div className="thread-page"> <div className="thread-header"> <button className="thread-back" onClick={() => navigate(-1)}><FontAwesomeIcon icon={faArrowLeft} /> Back</button> <span className="thread-header-title">{label('post', false, true)}</span> </div>
{renderParents(thread.parent)}
<div className="thread-main-post" ref={mainPostRef}> <div className="thread-post-detail"> <div className="thread-post-header"> <button className="thread-post-avatar-btn" onClick={() => handleOpenProfile(post.author.handle)} onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); handleProfileAuxClick(post.author.handle); } }} type="button" > {post.author.avatar ? ( <img className="thread-post-avatar" src={post.author.avatar} alt="" loading="lazy" /> ) : ( <div className="thread-post-avatar-placeholder">{displayName.charAt(0).toUpperCase()}</div> )} </button> <div className="thread-post-author-info"> <button className="thread-post-name-btn" onClick={() => handleOpenProfile(post.author.handle)} onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); handleProfileAuxClick(post.author.handle); } }} type="button" > <div className="thread-post-name-row"> <span className="thread-post-name">{displayName}</span> <PdsFavicon did={post.author.did} /> {pronouns && <span className="thread-post-pronouns">{pronouns}</span>} </div> <span className="thread-post-handle-row"> <HandleHoverCard handle={post.author.handle} did={post.author.did} className="thread-post-handle" onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); handleProfileAuxClick(post.author.handle); } }} /> {post.author.viewer?.followedBy && <span className="follows-you-badge">Follows you</span>} {isBlockedBy(post.author.did) && <span className="blocks-you-badge">Blocks you</span>} {isBlocked(post.author.did) && <span className="blocked-badge">Blocked</span>} {isMuted(post.author.did) && <span className="muted-badge">Muted</span>} {post.author.suspended && <span className="suspended-badge">Suspended</span>} </span> </button> <UserLabels labels={post.author.labels as Label[] | undefined} /> </div> </div>
<PostBody text={post.record.text} facets={post.record.facets} className="thread-post-body" />
<PostEmbeds embed={post.embed} onOpenPost={(embedUri, embedHandle, embedDid) => handleOpenPost(embedUri, embedHandle, embedDid)} />
<div className="thread-post-meta"> <span className="thread-post-time">{formatFullDate(post.indexedAt)}</span> </div>
<div className="thread-post-stats"> {typeof repostCount === 'number' && ( <button className="thread-stat-btn" onClick={() => setShowReposts(true)}> <strong>{repostCount}</strong> {label('repost', true)} </button> )} {typeof likeCount === 'number' && ( <button className="thread-stat-btn" onClick={() => setShowLikes(true)}> <strong>{likeCount}</strong> {label('like', true)} </button> )} {typeof post.quoteCount === 'number' && post.quoteCount > 0 && ( <span><strong>{post.quoteCount}</strong> quotes</span> )} </div>
<div className="thread-post-actions"> <button className={`post-action post-action--reply`} onClick={() => setShowReply(true)} > <FontAwesomeIcon icon={faCommentRegular} /> <span>{replyCount}</span> </button> <button className={`post-action post-action--repost${repostUri ? ' active' : ''}`} onClick={() => setShowRepostMenu(!showRepostMenu)} disabled={reposting} title={repostUri ? `Undo ${label('repost')}` : label('repost', false, true)} > <FontAwesomeIcon icon={faRetweet} /> <span>{repostCount}</span> </button>
{showRepostMenu && ( <div className="post-repost-menu" onClick={(e) => e.stopPropagation()}> <button className="post-repost-menu-item" onClick={async () => { setShowRepostMenu(false); await handleRepost(); }} disabled={reposting} > <FontAwesomeIcon icon={faRetweet} /> <span>{repostUri ? `Undo ${label('repost', false, true)}` : label('repost', false, true)}</span> </button> <button className="post-repost-menu-item" onClick={() => { setShowRepostMenu(false); setShowQuote(true); }} > <FontAwesomeIcon icon={faQuoteLeft} /> <span>Quote {label('post', false, true)}</span> </button> </div> )}
<button className={`post-action post-action--like${likeUri ? ' active' : ''}`} onClick={handleLike} disabled={liking} title={likeUri ? `Un${label('like')}` : label('like', false, true)} > <FontAwesomeIcon icon={likeUri ? faHeart : faHeartRegular} /> <span>{likeCount}</span> </button>
<button className={`post-action post-action--bookmark${bookmarked ? ' active' : ''}`} onClick={handleBookmark} disabled={bookmarking} title={bookmarked ? `Remove ${label('bookmark')}` : label('bookmark', false, true)} > <FontAwesomeIcon icon={bookmarked ? faBookmarkSolid : faBookmarkRegular} /> </button>
{(() => { const rkey = extractRkey(post.uri); if (!rkey) return null; const postRoute = buildPostPath(post.uri, post.author.handle, post.author.did); return ( <ShareButton handle={post.author.handle} rkey={rkey} localPath={postRoute} /> ); })()}
{session && session.did === post.author.did && ( <button className="post-action post-action--edit" onClick={() => setShowEdit(true)} title={`Edit ${label('post')}`} > <FontAwesomeIcon icon={faPenToSquare} /> </button> )}
<div className="post-more-menu-wrapper" ref={moreMenuRef}> <button className={`post-action post-action--more${showMoreMenu ? ' active' : ''}`} onClick={() => setShowMoreMenu(!showMoreMenu)} title="More" > <FontAwesomeIcon icon={faEllipsisVertical} /> </button>
{showMoreMenu && ( <div className="post-more-menu" onClick={(e) => e.stopPropagation()}> <button className="post-repost-menu-item" onClick={() => { setShowMoreMenu(false); handleMuteThread(); }} disabled={mutingThread} > <span>{threadMuted ? 'Unmute thread' : 'Mute thread'}</span> </button> <button className="post-repost-menu-item post-repost-menu-item--danger" onClick={() => { setShowMoreMenu(false); setShowReport(true); }} > <FontAwesomeIcon icon={faFlag} /> <span>Report {label('post', false, true)}</span> </button> </div> )} </div> </div> </div> </div>
{showQuote && thread && ( <ComposeModal quotePost={thread.post} onClose={() => setShowQuote(false)} onPosted={refreshThread} /> )}
{renderReplies(thread.replies)}
{(!thread.replies || thread.replies.length === 0) && ( <div className="thread-no-replies">No {label('reply', true)} yet</div> )}
{showEdit && ( <ComposeModal editPost={post} onClose={() => setShowEdit(false)} onPosted={refreshThread} /> )} {showReply && ( <ComposeModal replyTo={post} onClose={() => setShowReply(false)} onPosted={refreshThread} /> )} {showLikes && ( <LikesModal uri={post.uri} onClose={() => setShowLikes(false)} /> )} {showReposts && ( <RepostsModal uri={post.uri} onClose={() => setShowReposts(false)} /> )} {showReport && ( <ReportModal post={post} onClose={() => setShowReport(false)} /> )} </div> );}