Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
24 kB · 679 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680import { 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 ( <img className={className} src={proxied} alt={alt || ""} loading="lazy" onError={() => 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<string | null>(post.viewer?.like ?? null); const [likeCount, setLikeCount] = useState(post.likeCount ?? 0); const [repostUri, setRepostUri] = useState<string | null>(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<HTMLDivElement>(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 ( <article className={`post-card post-card--hidden${inThread ? ' post-card--thread' : ''}`}> <div className="post-hidden-banner"> <span className="post-hidden-text"> {authorBlocked ? 'Blocked' : 'Muted'} {label('post')} from <strong>{displayName}</strong> (@{post.author.handle}) </span> <button className="post-hidden-show-btn" onClick={() => setForceShowHidden(true)} type="button" > Show </button> </div> </article> ); }
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 ( <article className={`post-card${inThread ? ' post-card--thread' : ''}`} onClick={handlePostClick} onAuxClick={handlePostAuxClick} onMouseEnter={handleMouseEnter} > {!inThread && ( <a href={postRoute} className="post-card-link-overlay" tabIndex={-1} aria-hidden="true" onClick={(e) => e.preventDefault()} /> )} {reason?.$type === 'app.bsky.feed.defs#reasonRepost' && ( <div className="post-repost-label"> <FontAwesomeIcon icon={faRetweet} /><span>{(reason as { by: { handle: string } }).by.handle}</span> {label('repost')}ed </div> )}
{item.reply && (() => { const root = item.reply.root; if (!('author' in root)) return null; const rootAuthor = root.author; const rootText = 'record' in root && root.record?.text ? root.record.text : ''; const rootUri = root.uri; const rootAuthorDid = rootAuthor.did; const replyRoute = buildPostPath(rootUri, rootAuthor.handle, rootAuthorDid); return ( <a href={replyRoute} className="post-reply-context" onClick={(e) => { e.preventDefault(); e.stopPropagation(); switch (viewMode) { case 'page': navigate(replyRoute); break; case 'modal': case 'side': navigate(replyRoute); openPost(rootUri, viewMode); break; } }} > <div className="post-reply-context__line" /> {rootAuthor.avatar ? ( <AvatarImage className="post-reply-context__avatar" src={rootAuthor.avatar} /> ) : ( <div className="post-reply-context__avatar post-reply-context__avatar--placeholder"> {((rootAuthor.displayName ?? rootAuthor.handle) || '?').charAt(0).toUpperCase()} </div> )} <span className="post-reply-context__text"> {label('reply', false, true)} to{' '} <strong>{rootAuthor.displayName ?? rootAuthor.handle}</strong> {isBlocked(rootAuthorDid) && <span className="blocked-badge">Blocked</span>} {isMuted(rootAuthorDid) && <span className="muted-badge">Muted</span>} {rootAuthor.suspended && <span className="suspended-badge">Suspended</span>} {rootText && ( <span className="post-reply-context__preview"> {' '}· {rootText.length > 80 ? rootText.slice(0, 80) + '\u2026' : rootText} </span> )} </span> </a> ); })()}
<div className="post-header"> <HandleHoverCard handle={post.author.handle} did={post.author.did}> <button className="post-avatar-btn" onClick={(e) => { e.stopPropagation(); openProfileByMode(post.author.handle); }} onAuxClick={handleProfileAuxClick} type="button" > {post.author.avatar ? ( <AvatarImage className="post-avatar" src={post.author.avatar} /> ) : ( <div className="post-avatar-placeholder">{initial}</div> )} </button> </HandleHoverCard> <div className="post-author-info"> <HandleHoverCard handle={post.author.handle} did={post.author.did}> <button className="post-display-name-btn" onClick={(e) => { e.stopPropagation(); openProfileByMode(post.author.handle); }} onAuxClick={handleProfileAuxClick} type="button" > <div className="post-name-row"> <span className="post-display-name">{displayName}</span> <PdsFavicon did={post.author.did} /> {pronouns && <span className="post-pronouns">{pronouns}</span>} </div> </button> </HandleHoverCard> <span className="post-handle-row"> <HandleHoverCard handle={post.author.handle} did={post.author.did} className="post-handle" onAuxClick={handleProfileAuxClick} /> {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> <UserLabels labels={post.author.labels as Label[] | undefined} /> </div> <div className="post-header-right"> {session && session.did !== post.author.did && ( <FollowButton subjectDid={post.author.did} followingUri={post.author.viewer?.following} className="follow-btn--sm post-follow-btn" /> )} <span className="post-time">{relativeTime(post.indexedAt)}</span> </div> </div>
<PostBody text={post.record.text} facets={post.record.facets} className="post-body" />
<PostEmbeds embed={post.embed} onOpenPost={(embedUri, embedHandle, embedDid) => { const route = buildPostPath(embedUri, embedHandle, embedDid); switch (viewMode) { case 'page': navigate(route); break; case 'modal': case 'side': navigate(route); openPost(embedUri, viewMode); break; } }} />
<div className="post-footer"> <button className="post-action post-action--reply" onClick={(e) => { e.stopPropagation(); setShowReply(true); }}> <FontAwesomeIcon icon={faCommentRegular} /> <span>{replyCount}</span> </button>
<button className={`post-action post-action--repost${repostUri ? ' active' : ''}`} onClick={(e) => { e.stopPropagation(); 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={(e) => { e.stopPropagation(); 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={(e) => { e.stopPropagation(); 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; return ( <ShareButton handle={post.author.handle} rkey={rkey} localPath={postRoute} /> ); })()}
{session && session.did === post.author.did && ( <button className="post-action post-action--edit" onClick={(e) => { e.stopPropagation(); 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={(e) => { e.stopPropagation(); 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>
{showReply && ( <ComposeModal replyTo={post} onClose={() => setShowReply(false)} onPosted={onAction ?? (() => {})} /> )}
{showQuote && ( <ComposeModal quotePost={post} onClose={() => setShowQuote(false)} onPosted={onAction ?? (() => {})} /> )}
{showEdit && ( <ComposeModal editPost={post} onClose={() => setShowEdit(false)} onPosted={onAction ?? (() => {})} /> )}
{showReport && ( <ReportModal post={post} onClose={() => setShowReport(false)} /> )} </article> );}