Files
eranos/src/components/LeftSidebar.tsx
T
Alex Gleason 6f53f7ad99 Fix avatar fallback showing '?' instead of name initial
ComposeBox and LeftSidebar avatar fallbacks only checked metadata.name,
ignoring display_name and genUserName. Now uses the same fallback chain
as ProfileCard: display_name -> name -> genUserName(pubkey). Also fixed
the getDisplayName helper in LeftSidebar to check display_name.
2026-04-11 15:36:47 -05:00

321 lines
16 KiB
TypeScript

import { useState, useCallback } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
UserPlus, LogOut,
Loader2, QrCode,
} from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { DittoLogo } from '@/components/DittoLogo';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import LoginDialog from '@/components/auth/LoginDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useAppContext } from '@/hooks/useAppContext';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { isItemActive } from '@/lib/sidebarItems';
import { useUserStatus } from '@/hooks/useUserStatus';
import { usePublishStatus } from '@/hooks/usePublishStatus';
import { useToast } from '@/hooks/useToast';
import { Input } from '@/components/ui/input';
export function LeftSidebar() {
const location = useLocation();
const navigate = useNavigate();
const { user, metadata, event: currentUserEvent, isLoading: isProfileLoading } = useCurrentUser();
const currentUserAvatarShape = getAvatarShape(metadata);
const { currentUser, otherUsers, setLogin } = useLoggedInAccounts();
const { logout } = useLoginActions();
const {
orderedItems, hiddenItems, updateSidebarOrder, addToSidebar, addDividerToSidebar, removeFromSidebar,
} = useFeedSettings();
const { config } = useAppContext();
const visibleItems = orderedItems;
const visibleHiddenItems = hiddenItems;
const hasUnread = useHasUnreadNotifications();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const { startSignup } = useOnboarding();
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const [editing, setEditing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
// NIP-38 status
const userStatus = useUserStatus(user?.pubkey);
const publishStatus = usePublishStatus();
const { toast } = useToast();
const [statusEditing, setStatusEditing] = useState(false);
const [statusDraft, setStatusDraft] = useState('');
const homePage = config.homePage;
const scrollToTopIfCurrent = useCallback((to: string) => (e: React.MouseEvent) => {
if (location.pathname === to) {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [location.pathname]);
const getDisplayName = (account: Account) => account.metadata.display_name || account.metadata.name || genUserName(account.pubkey);
const handleLogout = async () => {
setAccountPopoverOpen(false);
await logout();
navigate('/');
};
return (
<aside className="flex flex-col h-screen sticky top-0 py-3 px-4 w-[300px] shrink-0">
{/* Logo */}
<div className="flex items-center px-3 mb-1">
<Link to="/" onClick={scrollToTopIfCurrent('/')}>
<div className="bg-background/85 rounded-full">
<DittoLogo size={48} />
</div>
</Link>
</div>
{/* Search */}
<div className="px-2 py-4">
<ProfileSearchDropdown placeholder="Search..." inputClassName="py-3.5" enableTextSearch />
</div>
{/* Nav */}
<nav className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<SidebarNavList
items={visibleItems}
editing={editing}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={(id) => id === homePage ? scrollToTopIfCurrent('/') : undefined}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
homePage={homePage}
/>
<SidebarMoreMenu
editing={editing}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
</nav>
{/* Logged-out join pill — same position as account button, pushed up from bottom */}
{!user && location.pathname !== '/' && (
<div className="pt-2 pb-1">
<button
onClick={() => setLoginDialogOpen(true)}
className="flex items-center justify-center w-full h-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors cursor-pointer"
>
Join
</button>
</div>
)}
{/* User profile at bottom */}
{user && currentUser && (
<div className="pt-2">
<Popover open={accountPopoverOpen} onOpenChange={setAccountPopoverOpen}>
<PopoverTrigger asChild>
<button className="flex items-center gap-3 p-3 rounded-full hover:bg-secondary/60 transition-colors cursor-pointer w-full text-left bg-background/85">
{isProfileLoading ? (
<Skeleton className="size-10 shrink-0 rounded-full" />
) : (
<Avatar shape={currentUserAvatarShape} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{(metadata?.display_name || metadata?.name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
)}
<div className="flex flex-col min-w-0 flex-1 gap-1">
{isProfileLoading ? (
<><Skeleton className="h-3.5 w-24" /><Skeleton className="h-3 w-16" /></>
) : (
<>
<span className="font-semibold text-sm truncate">
{currentUserEvent && metadata?.name
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name}</EmojifiedText>
: (metadata?.name || genUserName(user.pubkey))}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={user.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</>
)}
</div>
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" sideOffset={8} className="w-[260px] p-0 rounded-2xl shadow-xl border border-border overflow-hidden">
{/* Current user */}
<Link to={userProfileUrl} onClick={() => setAccountPopoverOpen(false)} className="block p-4 border-b border-border hover:bg-secondary/60 transition-colors">
<div className="flex items-center gap-3">
<Avatar shape={currentUserAvatarShape} className="size-11 shrink-0">
<AvatarImage src={currentUser.metadata.picture} alt={getDisplayName(currentUser)} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{getDisplayName(currentUser).charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="font-bold text-sm truncate">
{currentUser.event ? <EmojifiedText tags={currentUser.event.tags}>{getDisplayName(currentUser)}</EmojifiedText> : getDisplayName(currentUser)}
</span>
{currentUser.metadata.nip05 && (
<VerifiedNip05Text nip05={currentUser.metadata.nip05} pubkey={currentUser.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
</div>
</Link>
{/* Status editor */}
<div className="border-b border-border">
{statusEditing ? (
<div className="p-3 space-y-2">
<Input
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
placeholder="What are you up to?"
className="h-8 text-base md:text-sm"
maxLength={80}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
} else if (e.key === 'Escape') {
setStatusEditing(false);
setStatusDraft('');
}
}}
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => {
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
>
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
</button>
{userStatus.status && (
<button
onClick={() => {
publishStatus.mutateAsync({ status: '' }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
>
Clear
</button>
)}
<button
onClick={() => { setStatusEditing(false); setStatusDraft(''); }}
className="text-xs text-muted-foreground hover:underline ml-auto"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => {
setStatusEditing(true);
setStatusDraft(userStatus.status ?? '');
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
>
{userStatus.status ? (
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
) : (
<span className="text-muted-foreground">Set a status</span>
)}
</button>
)}
</div>
{/* Other accounts */}
{otherUsers.length > 0 && (
<div className="border-b border-border">
{otherUsers.map((account) => (
<button key={account.id} onClick={() => { setLogin(account.id); setAccountPopoverOpen(false); }} className="flex items-center gap-3 w-full px-4 py-3 hover:bg-secondary/60 transition-colors">
<Avatar shape={getAvatarShape(account.metadata)} className="size-9 shrink-0">
<AvatarImage src={account.metadata.picture} alt={getDisplayName(account)} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">{getDisplayName(account).charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">
{account.event ? <EmojifiedText tags={account.event.tags}>{getDisplayName(account)}</EmojifiedText> : getDisplayName(account)}
</span>
{account.metadata.nip05 && <VerifiedNip05Text nip05={account.metadata.nip05} pubkey={account.pubkey} className="text-xs text-muted-foreground truncate" />}
</div>
</button>
))}
</div>
)}
{/* Actions */}
<div className="py-1">
<button onClick={() => { setAccountPopoverOpen(false); setFollowQROpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
<QrCode className="size-4 text-muted-foreground" />
<span>Share profile</span>
</button>
<button onClick={() => { setAccountPopoverOpen(false); setLoginDialogOpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
<UserPlus className="size-4 text-muted-foreground" />
<span>Add another account</span>
</button>
<button onClick={handleLogout} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors">
<LogOut className="size-4" />
<span>Log out @{metadata?.name || genUserName(user.pubkey)}</span>
</button>
</div>
</PopoverContent>
</Popover>
</div>
)}
<LoginDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} onLogin={() => setLoginDialogOpen(false)} onSignupClick={startSignup} />
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
</aside>
);
}