Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
22 kB · 614 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615import { 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<ThreadViewPost | null>(cachedThread); const [loading, setLoading] = useState(cachedLoading); const [error, setError] = useState<string | null>(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<string | null>(thread?.post.viewer?.like ?? null); const [likeCount, setLikeCount] = useState(thread?.post.likeCount ?? 0); const [repostUri, setRepostUri] = useState<string | null>(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<HTMLDivElement>(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<string>(); 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) => ( <div key={node.post.uri} className="thread-ancestor"> <div className="thread-reply-clickable" onClick={() => 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); } }}> <PostCard item={asFeedViewPost(node.post)} inThread onAction={refreshThread} /> </div> </div> )); }
function renderReplies(replies: (ThreadViewPost | unknown)[] | undefined, depth = 0): ReactNode { if (!replies || replies.length === 0) return null;
const seenUris = new Set<string>();
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( <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); allReplies.push( <div key={key} className="thread-reply"> <div className="thread-reply-clickable" onClick={() => 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); } }}> <PostCard item={asFeedViewPost(reply.post)} inThread onAction={refreshThread} /> </div> </div> ); if (reply.replies && reply.replies.length > 0) { flatten(reply.replies); } } } } flatten(replies); return <div className="thread-replies thread-replies--flat">{allReplies}</div>; }
return ( <div className={`thread-replies thread-replies--depth-${Math.min(depth, 3)}`}> {replies.map((reply, i) => { 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); return ( <div key={key} className="thread-reply"> <div className="thread-reply-clickable" onClick={() => 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); } }}> <PostCard item={asFeedViewPost(reply.post)} inThread onAction={refreshThread} /> </div> {reply.replies && reply.replies.length > 0 && renderReplies(reply.replies, depth + 1)} </div> ); })} </div> ); }
if (loading) { return <div className="thread-loading">Loading thread…</div>; }
if (error || !thread) { return <div className="thread-error">{error ?? 'Post not found'}</div>; }
const post = thread.post; const displayName = post.author.displayName ?? post.author.handle; const pronouns = post.author.pronouns;
return ( <div className="thread-page"> {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> <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); } }} /> </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> <button className="post-repost-menu-item post-repost-menu-item--danger" onClick={() => { setShowRepostMenu(false); setShowReport(true); }} > <FontAwesomeIcon icon={faFlag} /> <span>Report {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>
{(() => { 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} /> ); })()}
<button className={`post-action post-action--mute-thread${threadMuted ? ' active' : ''}`} onClick={handleMuteThread} disabled={mutingThread} title={threadMuted ? 'Unmute thread' : 'Mute thread'} > <FontAwesomeIcon icon={threadMuted ? faBellSlash : faBellSlashRegular} /> </button>
{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> </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> );}