import { 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 (
e.key === 'Enter' && handleView()}> {feed.avatar ? ( ) : (
{feed.displayName.charAt(0).toUpperCase()}
)}
{feed.displayName} {feed.likeCount !== undefined && ( {feed.likeCount} )}
{isSaved && ( )}
); } 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(null); const [activeTab, setActiveTab] = useState('posts'); const [feed, setFeed] = useState([]); const [loading, setLoading] = useState(true); const [loadingFeed, setLoadingFeed] = useState(true); const [cursor, setCursor] = useState(); const [loadingMore, setLoadingMore] = useState(false); const [showFollowers, setShowFollowers] = useState(false); const [showFollowing, setShowFollowing] = useState(false); const [profileFeeds, setProfileFeeds] = useState([]); const [loadingProfileFeeds, setLoadingProfileFeeds] = useState(false); const isOpen = activeActor !== null; const actorHandle = useRef(null); const actorDid = useRef(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 (
Profile
{loading ? (
Loading profile…
) : !profile ? (
Profile not found
) : (
{profile.banner && (
)}
{profile.avatar ? ( ) : (
{displayName.charAt(0).toUpperCase()}
)}
{session && session.did !== profile.did && ( { setProfile((prev) => prev ? { ...prev, viewer: { ...prev.viewer, muted: m }, } : prev); }} size="sm" /> )}
{displayName} {profile.pronouns && ( {profile.pronouns} )}
{profile.viewer?.followedBy && Follows you} {isBlockedBy(profile.did) && Blocks you} {isBlocked(profile.did) && Blocked} {profile.viewer?.muted && Muted} {profile.suspended && Suspended}
{profile.description && (
{profile.description}
)} {profile.website && ( {extractDisplayUrl(profile.website)} )}
{profile.indexedAt && ( Joined {formatDate(profile.indexedAt)} )}
{formatCount(profile.postsCount ?? 0)} {label('post', true)}
{TABS.map((tab) => ( ))}
{activeTab === 'feeds' ? ( <> {loadingProfileFeeds &&
Loading feeds…
} {!loadingProfileFeeds && profileFeeds.length === 0 && (
No custom feeds
)} {profileFeeds.map((fg) => ( ))} ) : ( <> {loadingFeed && feed.length === 0 &&
Loading…
} {!loadingFeed && feed.length === 0 && (
{activeTab === 'posts' ? `No ${label('post', true)} yet` : 'No content found'}
)} {feed.map((item) => (
{item._pinned && (
📌 Pinned
)}
))} {cursor && ( )} )}
)} {showFollowers && profile && ( setShowFollowers(false)} /> )} {showFollowing && profile && ( setShowFollowing(false)} /> )}
); }