Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
23 kB · 654 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655import { useState, useCallback, useEffect, useRef } from 'react';import { useNavigate } from 'react-router-dom';import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';import { faCalendar, faImage, faPlay, faHeart, faComment, faListUl, faLink, faRss, faBookmark, faThumbtack, faCircleCheck, faShareNodes,} from '@fortawesome/free-solid-svg-icons';import type { ProfileViewDetailed, FeedViewPost, Label, FeedGeneratorView as FeedGenView } from '../api/types';
interface PinnedFeedItem extends FeedViewPost { _pinned?: boolean;}import { atprotoClient } from '../api/client';import { useSavedFeeds } from '../hooks/useSavedFeeds';import { prefetchProfileTab, prefetchProfileLikes, prefetchFollowers, prefetchFollows, getCachedProfileTab,} from '../prefetch';import { useAuth } from '../auth/useAuth';import { usePostLabels } from '../settings/usePostLabels';import { useProfileViewer } from './useProfileViewer';import { useNavigationMemory } from './useNavigationMemory';import PostCard from './PostCard';import FollowListModal from './FollowListModal';import ProfileActions from './ProfileActions';import UserLabels from './UserLabels';import HandleHoverCard from './HandleHoverCard';import PdsFavicon from './PdsFavicon';import { useMuteBlock } from './muteBlockContext';import './ProfileSide.css';
type ProfileTab = 'posts' | 'replies' | 'media' | 'videos' | 'likes' | 'feeds';
const TABS: { key: ProfileTab; label: string; icon: typeof faListUl }[] = [ { key: 'posts', label: '__POSTS__', icon: faListUl }, { key: 'replies', label: '__REPLIES__', icon: faComment }, { key: 'media', label: 'Media', icon: faImage }, { key: 'videos', label: 'Videos', icon: faPlay }, { key: 'likes', label: '__LIKES__', icon: faHeart }, { key: 'feeds', label: 'Feeds', icon: faRss },];
function tabToFilter(tab: ProfileTab): string | undefined { switch (tab) { case 'posts': return 'posts_no_replies'; case 'replies': return 'posts_with_replies'; case 'media': return 'posts_with_media'; case 'videos': return 'posts_with_video'; case 'likes': return undefined; case 'feeds': return undefined; }}
function formatCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n);}
function formatDate(isoDate: string): string { const d = new Date(isoDate); return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });}
function extractDisplayUrl(url: string): string { try { const u = new URL(url); return u.hostname + (u.pathname !== '/' ? u.pathname : ''); } catch { return url; }}
function SidePanelFeedCard({ feed }: { feed: FeedGenView }) { const navigate = useNavigate(); const { feeds, addFeed, removeFeed, togglePin } = useSavedFeeds(); const [saving, setSaving] = useState(false); const [copied, setCopied] = useState(false);
const savedEntry = feeds.find((f) => f.value === feed.uri); const isSaved = !!savedEntry;
const sharePath = (() => { const m = feed.uri.match(/^at:\/\/(did:[^/]+)\/app\.bsky\.feed\.generator\/(.+)$/); if (!m) return null; return `/feed/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`; })();
const handleView = () => { if (sharePath) navigate(sharePath); };
const handleSave = async () => { setSaving(true); try { if (isSaved && savedEntry) { await removeFeed(savedEntry.id); } else { await addFeed(feed.uri); } } catch (err) { console.error('Failed to toggle save:', err); } finally { setSaving(false); } };
const handlePin = async () => { if (!savedEntry) return; setSaving(true); try { await togglePin(savedEntry.id); } catch (err) { console.error('Failed to toggle pin:', err); } finally { setSaving(false); } };
const handleLike = async () => { setSaving(true); try { if (feed.viewer?.like) { await atprotoClient.unlikeFeedGenerator(feed.viewer.like); } else { await atprotoClient.likeFeedGenerator(feed.uri, feed.cid); } } catch (err) { console.error('Failed to toggle like:', err); } finally { setSaving(false); } };
const handleShare = () => { if (!sharePath) return; const url = `${window.location.origin}${sharePath}`; navigator.clipboard.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); };
return ( <div className="profile-feed-card"> <div className="profile-feed-card-info" onClick={handleView} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && handleView()}> {feed.avatar ? ( <img className="profile-feed-card-avatar" src={feed.avatar} alt="" /> ) : ( <div className="profile-feed-card-avatar-placeholder"> {feed.displayName.charAt(0).toUpperCase()} </div> )} <div className="profile-feed-card-text"> <span className="profile-feed-card-name">{feed.displayName}</span> {feed.likeCount !== undefined && ( <span className="profile-feed-card-meta"> <FontAwesomeIcon icon={faHeart} />{feed.likeCount} </span> )} </div> </div> <div className="profile-feed-card-actions"> <button className="profile-feed-card-btn" onClick={handleLike} disabled={saving} title={feed.viewer?.like ? 'Unlike' : 'Like'} type="button" > <FontAwesomeIcon icon={faHeart} style={feed.viewer?.like ? { color: '#ef4444' } : undefined} /> </button> <button className="profile-feed-card-btn" onClick={handleSave} disabled={saving} title={isSaved ? 'Remove from saved' : 'Save feed'} type="button" > <FontAwesomeIcon icon={faBookmark} style={isSaved ? { color: 'var(--accent)' } : undefined} /> </button> {isSaved && ( <button className="profile-feed-card-btn" onClick={handlePin} disabled={saving} title={savedEntry?.pinned ? 'Unpin' : 'Pin'} type="button" > <FontAwesomeIcon icon={faThumbtack} style={savedEntry?.pinned ? { color: 'var(--accent)' } : undefined} /> </button> )} <button className="profile-feed-card-btn" onClick={handleShare} title="Copy share link" type="button" > <FontAwesomeIcon icon={copied ? faCircleCheck : faShareNodes} style={copied ? { color: '#22c55e' } : undefined} /> </button> </div> </div> );}
export default function ProfileSidePanel() { const { activeActor, close } = useProfileViewer(); const navigate = useNavigate(); const { session } = useAuth(); const { mainContentPath } = useNavigationMemory(); const { t: label } = usePostLabels(); const { isBlocked, isBlockedBy, addBlockedBy, ignoreBlocks } = useMuteBlock();
const mainContentPathRef = useRef(mainContentPath); mainContentPathRef.current = mainContentPath;
const [profile, setProfile] = useState<ProfileViewDetailed | null>(null); const [activeTab, setActiveTab] = useState<ProfileTab>('posts'); const [feed, setFeed] = useState<PinnedFeedItem[]>([]); const [loading, setLoading] = useState(true); const [loadingFeed, setLoadingFeed] = useState(true); const [cursor, setCursor] = useState<string | undefined>(); const [loadingMore, setLoadingMore] = useState(false); const [showFollowers, setShowFollowers] = useState(false); const [showFollowing, setShowFollowing] = useState(false); const [profileFeeds, setProfileFeeds] = useState<FeedGenView[]>([]); const [loadingProfileFeeds, setLoadingProfileFeeds] = useState(false);
const isOpen = activeActor !== null; const actorHandle = useRef<string | null>(null); const actorDid = useRef<string | null>(null);
const shouldUsePublicFeed = ignoreBlocks && isBlockedBy(profile?.did ?? '');
const handleClose = useCallback(() => { navigate(mainContentPathRef.current, { replace: true }); close(); }, [navigate, close]);
// Reset profile when activeActor changes (handles navigation between profiles) useEffect(() => { setProfile(null); setFeed([]); setCursor(undefined); setLoading(true); setLoadingFeed(true); actorHandle.current = null; actorDid.current = null; }, [activeActor]);
useEffect(() => { if (!activeActor) return; let cancelled = false;
(async () => { try { const p = await atprotoClient.getActorProfile(activeActor); if (!cancelled) { setProfile(p); actorHandle.current = p.handle; actorDid.current = p.did; setLoading(false); if (p.viewer?.blockedBy) addBlockedBy(p.did); } } catch (err) { if (!cancelled) { console.error('Failed to load profile:', err); setLoading(false); } } })();
return () => { cancelled = true; }; }, [activeActor]);
useEffect(() => { if (!activeActor) return; let cancelled = false;
if (activeTab === 'feeds') return;
(async () => { // Use activeActor directly — profile may be stale/null during actor transitions const handle = actorHandle.current ?? activeActor; setLoadingFeed(true);
try { const cacheKey = activeTab === 'likes' ? 'likes' : (tabToFilter(activeTab) ?? 'posts_with_replies'); const cachedResp = getCachedProfileTab<{ cursor?: string; feed: FeedViewPost[] }>(handle, cacheKey); if (cachedResp && !shouldUsePublicFeed) { let cachedItems: PinnedFeedItem[] = cachedResp.feed;
if (activeTab === 'posts' && profile?.pinnedPost?.uri) { const pinnedUri = profile.pinnedPost.uri; cachedItems = cachedItems.filter((i) => i.post.uri !== pinnedUri); try { const [pinnedPost] = shouldUsePublicFeed ? await atprotoClient.getPostsPublic([pinnedUri]) : await atprotoClient.getPosts([pinnedUri]); if (pinnedPost && !cancelled) { cachedItems = [{ post: pinnedPost, _pinned: true } as PinnedFeedItem, ...cachedItems]; } } catch { } }
if (!cancelled) { setFeed(cachedItems); setCursor(cachedResp.cursor); setLoadingFeed(false); } return; }
// If not in cache and not already set, clear feed before fetching if (!cancelled) { setFeed([]); setCursor(undefined); }
let resp: { cursor?: string; feed: FeedViewPost[] }; if (activeTab === 'likes') { const likesDid = actorDid.current ?? handle; const isOwnProfile = session?.did === likesDid; if (isOwnProfile) { resp = await atprotoClient.getActorLikes(likesDid, 30); } else { resp = await atprotoClient.getActorLikesViaRecords(likesDid, 25); } } else if (shouldUsePublicFeed) { resp = await atprotoClient.getAuthorFeedPublic(handle, 30, undefined, tabToFilter(activeTab) as any); } else { resp = await atprotoClient.getAuthorFeed(handle, 30, undefined, tabToFilter(activeTab) as any); }
if (!cancelled) { let feedItems: PinnedFeedItem[] = resp.feed;
if (activeTab === 'posts' && profile?.pinnedPost?.uri) { const pinnedUri = profile.pinnedPost.uri; feedItems = feedItems.filter((i) => i.post.uri !== pinnedUri); try { const [pinnedPost] = shouldUsePublicFeed ? await atprotoClient.getPostsPublic([pinnedUri]) : await atprotoClient.getPosts([pinnedUri]); if (pinnedPost && !cancelled) { feedItems = [{ post: pinnedPost, _pinned: true } as PinnedFeedItem, ...feedItems]; } } catch { } }
if (!cancelled) { setFeed(feedItems); setCursor(resp.cursor); setLoadingFeed(false); } } } catch (err) { if (!cancelled) { console.error('Failed to load feed:', err); setLoadingFeed(false); } } })();
return () => { cancelled = true; }; }, [activeActor, activeTab, profile?.pinnedPost?.uri, shouldUsePublicFeed]);
useEffect(() => { if (activeTab !== 'feeds' || !profile) return; let cancelled = false;
(async () => { setLoadingProfileFeeds(true); try { const resp = await atprotoClient.getActorFeeds(profile.did, 30); if (!cancelled) { setProfileFeeds(resp.feeds); setLoadingProfileFeeds(false); } } catch (err) { if (!cancelled) { console.error('Failed to load feeds:', err); setLoadingProfileFeeds(false); } } })();
return () => { cancelled = true; }; }, [activeTab, profile?.did]);
const loadMore = useCallback(async () => { if (loadingMore || !cursor) return; const handle = actorHandle.current ?? activeActor; if (!handle) return;
setLoadingMore(true); try { let resp: { cursor?: string; feed: FeedViewPost[] }; if (activeTab === 'likes') { const likesDid = actorDid.current ?? handle; const isOwnProfile = session?.did === likesDid; if (isOwnProfile) { resp = await atprotoClient.getActorLikes(likesDid, 30, cursor); } else { resp = await atprotoClient.getActorLikesViaRecords(likesDid, 25, cursor); } } else if (shouldUsePublicFeed) { resp = await atprotoClient.getAuthorFeedPublic(handle, 30, cursor, tabToFilter(activeTab) as 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' | 'posts_with_video' | undefined); } else { resp = await atprotoClient.getAuthorFeed(handle, 30, cursor, tabToFilter(activeTab) as 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' | 'posts_with_video' | undefined); } setFeed((prev) => { const seen = new Set(prev.map((i) => i.post.uri)); const newItems = resp.feed.filter((i) => !seen.has(i.post.uri)); return [...prev, ...newItems]; }); setCursor(resp.cursor); } catch (err) { console.error('Failed to load more:', err); } finally { setLoadingMore(false); } }, [loadingMore, cursor, activeActor, activeTab, ignoreBlocks, shouldUsePublicFeed]);
const handleTabHover = useCallback((tab: ProfileTab) => { const handle = actorHandle.current ?? activeActor; const did = actorDid.current; if (!handle) return;
if (tab === 'likes') { const likesDid = did ?? handle; const isOwnProfile = session?.did === likesDid; prefetchProfileLikes(likesDid, isOwnProfile); } else if (tab !== 'feeds') { const filter = tabToFilter(tab); prefetchProfileTab(handle, filter); } }, [activeActor]);
if (!isOpen) return null;
const displayName = profile?.displayName || profile?.handle || activeActor;
return ( <div className="profile-side-panel open"> <div className="profile-side-header"> <span className="profile-side-title">Profile</span> <button className="profile-side-close" onClick={handleClose}> ✕ </button> </div>
{loading ? ( <div className="profile-side-loading">Loading profile…</div> ) : !profile ? ( <div className="profile-side-error">Profile not found</div> ) : ( <div className="profile-side-body"> {profile.banner && ( <div className="profile-side-banner"> <img src={profile.banner} alt="" /> </div> )}
<div className={`profile-side-info${!profile.banner ? ' no-banner' : ''}`}> <div className="profile-side-info-top"> <div className="profile-side-avatar-wrap"> {profile.avatar ? ( <img className="profile-side-avatar" src={profile.avatar} alt="" /> ) : ( <div className="profile-side-avatar-placeholder"> {displayName.charAt(0).toUpperCase()} </div> )} </div>
{session && session.did !== profile.did && ( <ProfileActions subjectDid={profile.did} followingUri={profile.viewer?.following} blockingUri={profile.viewer?.blocking} muted={profile.viewer?.muted} onMuteChange={(m) => { setProfile((prev) => prev ? { ...prev, viewer: { ...prev.viewer, muted: m }, } : prev); }} size="sm" /> )} </div>
<div className="profile-side-names"> <div className="profile-side-name-row"> <span className="profile-side-display-name">{displayName}</span> <PdsFavicon did={profile.did} /> {profile.pronouns && ( <span className="profile-side-pronouns">{profile.pronouns}</span> )} </div> <span className="profile-side-handle-row"> <HandleHoverCard handle={profile.handle} did={profile.did} className="profile-side-handle" /> {profile.viewer?.followedBy && <span className="follows-you-badge">Follows you</span>} {isBlockedBy(profile.did) && <span className="blocks-you-badge">Blocks you</span>} {isBlocked(profile.did) && <span className="blocked-badge">Blocked</span>} {profile.viewer?.muted && <span className="muted-badge">Muted</span>} {profile.suspended && <span className="suspended-badge">Suspended</span>} </span> </div> <UserLabels labels={profile.labels as Label[] | undefined} />
{profile.description && ( <div className="profile-side-bio">{profile.description}</div> )}
{profile.website && ( <a className="profile-side-website" href={profile.website} target="_blank" rel="noopener noreferrer" > <FontAwesomeIcon icon={faLink} /> {extractDisplayUrl(profile.website)} </a> )}
<div className="profile-side-meta"> {profile.indexedAt && ( <span className="profile-side-meta-item"> <FontAwesomeIcon icon={faCalendar} /> Joined {formatDate(profile.indexedAt)} </span> )} </div>
<div className="profile-side-stats"> <button className="profile-side-stat profile-side-stat--clickable" onClick={() => setShowFollowing(true)} onMouseEnter={() => prefetchFollows(profile.handle)} > <strong>{formatCount(profile.followsCount ?? 0)}</strong> following </button> <button className="profile-side-stat profile-side-stat--clickable" onClick={() => setShowFollowers(true)} onMouseEnter={() => prefetchFollowers(profile.handle)} > <strong>{formatCount(profile.followersCount ?? 0)}</strong> followers </button> <span className="profile-side-stat"> <strong>{formatCount(profile.postsCount ?? 0)}</strong> {label('post', true)} </span> </div> </div>
<div className="profile-side-tabs"> {TABS.map((tab) => ( <button key={tab.key} className={`profile-side-tab${activeTab === tab.key ? ' active' : ''}`} onClick={() => setActiveTab(tab.key)} onMouseEnter={() => handleTabHover(tab.key)} > <FontAwesomeIcon icon={tab.icon} className="profile-side-tab-icon" /> <span className="profile-side-tab-label">{tab.label === '__POSTS__' ? label('post', true, true) : tab.label === '__LIKES__' ? label('like', true, true) : tab.label === '__REPLIES__' ? label('reply', true, true) : tab.label}</span> </button> ))} </div>
<div className="profile-side-feed"> {activeTab === 'feeds' ? ( <> {loadingProfileFeeds && <div className="profile-side-loading">Loading feeds…</div>} {!loadingProfileFeeds && profileFeeds.length === 0 && ( <div className="profile-side-empty">No custom feeds</div> )} {profileFeeds.map((fg) => ( <SidePanelFeedCard key={fg.uri} feed={fg} /> ))} </> ) : ( <> {loadingFeed && feed.length === 0 && <div className="profile-side-loading">Loading…</div>} {!loadingFeed && feed.length === 0 && ( <div className="profile-side-empty"> {activeTab === 'posts' ? `No ${label('post', true)} yet` : 'No content found'} </div> )} {feed.map((item) => ( <div key={item.post.uri} className={item._pinned ? 'profile-side-pinned-post' : undefined}> {item._pinned && ( <div className="profile-side-pinned-label"> <span className="profile-side-pinned-icon">📌</span> Pinned </div> )} <PostCard item={item} /> </div> ))} {cursor && ( <button className="profile-side-load-more" onClick={loadMore} disabled={loadingMore}> {loadingMore ? 'Loading…' : 'Load more'} </button> )} </> )} </div> </div> )}
{showFollowers && profile && ( <FollowListModal actor={profile.handle} mode="followers" onClose={() => setShowFollowers(false)} /> )} {showFollowing && profile && ( <FollowListModal actor={profile.handle} mode="following" onClose={() => setShowFollowing(false)} /> )} </div> );}