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)}
/>
)}
);
}