You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
301 lines
10 KiB
301 lines
10 KiB
import LoginDialog from '@/components/LoginDialog' |
|
import LogoutDialog from '@/components/LogoutDialog' |
|
import SidebarItem from '@/components/Sidebar/SidebarItem' |
|
import { Avatar, AvatarFallback, AvatarIdenticon, AvatarImage } from '@/components/ui/avatar' |
|
import { Button } from '@/components/ui/button' |
|
import { |
|
DropdownMenu, |
|
DropdownMenuContent, |
|
DropdownMenuItem, |
|
DropdownMenuSeparator, |
|
DropdownMenuTrigger |
|
} from '@/components/ui/dropdown-menu' |
|
import { Skeleton } from '@/components/ui/skeleton' |
|
import { |
|
accountPubkeyToHex, |
|
formatPubkey, |
|
formatNpub, |
|
generateImageByPubkey, |
|
hexPubkeysEqual, |
|
pubkeyToNpub |
|
} from '@/lib/pubkey' |
|
import { isVideo } from '@/lib/url' |
|
import { cn } from '@/lib/utils' |
|
import { openBrowseCacheFromRegistry } from '@/contexts/cache-browser-context' |
|
import { toCacheSettings } from '@/lib/link' |
|
import { usePrimaryPage } from '@/contexts/primary-page-context' |
|
import { useSmartSettingsNavigation } from '@/PageManager' |
|
import { useFetchProfile } from '@/hooks/useFetchProfile' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
import { AccountQuickSwitchMenuItems } from '@/components/AccountQuickSwitchMenuItems' |
|
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' |
|
import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' |
|
import { useCallback, useMemo, useState, type ReactNode } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import type { TProfile } from '@/types' |
|
|
|
/** Profile for the badge only when it belongs to the active session pubkey (avoids stale name/avatar). */ |
|
function profileForActivePubkey( |
|
pubkey: string | undefined, |
|
nostrProfile: TProfile | null, |
|
fetchedProfile: TProfile | null |
|
): TProfile | null { |
|
const pk = pubkey ? accountPubkeyToHex(pubkey) : null |
|
if (!pk) return null |
|
if (fetchedProfile && hexPubkeysEqual(fetchedProfile.pubkey, pk)) return fetchedProfile |
|
if (nostrProfile && hexPubkeysEqual(nostrProfile.pubkey, pk)) return nostrProfile |
|
return null |
|
} |
|
|
|
const titlebarAccountMenuContentClassName = |
|
'z-[220] w-[min(18rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain' |
|
|
|
export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar' |
|
|
|
function AccountDropdownItems({ |
|
onSwitchAccount, |
|
onLogoutClick, |
|
onBrowseCache, |
|
onCloseMenu |
|
}: { |
|
onSwitchAccount: () => void |
|
onLogoutClick: () => void |
|
onBrowseCache: () => void |
|
onCloseMenu?: () => void |
|
}) { |
|
const { t } = useTranslation() |
|
const { navigate } = usePrimaryPage() |
|
|
|
return ( |
|
<> |
|
<ReadOnlySessionIndicator variant="menu" /> |
|
<AccountQuickSwitchMenuItems onAfterSwitch={onCloseMenu} /> |
|
<DropdownMenuItem onClick={() => navigate('profile')}> |
|
<User className="size-4" /> |
|
{t('Profile')} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={() => navigate('settings')}> |
|
<Settings className="size-4" /> |
|
{t('Settings')} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={onBrowseCache}> |
|
<Database className="size-4" /> |
|
{t('Browse Cache')} |
|
</DropdownMenuItem> |
|
<DropdownMenuSeparator /> |
|
<DropdownMenuItem onClick={onSwitchAccount}> |
|
<ArrowDownUp className="size-4" /> |
|
{t('Switch account')} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={onLogoutClick}> |
|
<LogOut className="size-4" /> |
|
{t('Logout')} |
|
</DropdownMenuItem> |
|
</> |
|
) |
|
} |
|
|
|
function SidebarAccountMenu({ |
|
onSwitchAccount, |
|
onLogoutClick, |
|
onBrowseCache |
|
}: { |
|
onSwitchAccount: () => void |
|
onLogoutClick: () => void |
|
onBrowseCache: () => void |
|
}) { |
|
const { t } = useTranslation() |
|
const { account, profile } = useNostr() |
|
const { current, display } = usePrimaryPage() |
|
const [menuOpen, setMenuOpen] = useState(false) |
|
const pubkey = account?.pubkey |
|
const { profile: fetchedProfile } = useFetchProfile(pubkey) |
|
const resolvedProfile = useMemo( |
|
() => profileForActivePubkey(pubkey, profile, fetchedProfile), |
|
[pubkey, profile, fetchedProfile] |
|
) |
|
const active = useMemo(() => current === 'profile' && display, [display, current]) |
|
|
|
if (!pubkey) return null |
|
|
|
const defaultAvatar = generateImageByPubkey(pubkey) |
|
const npub = pubkeyToNpub(pubkey) |
|
const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey) |
|
const { username, avatar } = resolvedProfile |
|
? { username: resolvedProfile.username, avatar: resolvedProfile.avatar ?? defaultAvatar } |
|
: { username: fallbackUsername, avatar: defaultAvatar } |
|
|
|
return ( |
|
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> |
|
<DropdownMenuTrigger asChild> |
|
<Button |
|
type="button" |
|
variant="ghost" |
|
title={t('Account menu')} |
|
aria-label={t('Account menu')} |
|
className={cn( |
|
'clickable h-12 min-w-0 justify-start gap-2 rounded-lg bg-transparent p-2 text-lg font-semibold text-foreground shadow-none hover:text-accent-foreground', |
|
'w-12 xl:w-full xl:px-2 xl:py-2', |
|
active && 'bg-accent/50' |
|
)} |
|
> |
|
{isVideo(avatar ?? '') ? ( |
|
<div className="size-8 shrink-0 overflow-hidden rounded-full"> |
|
<video src={avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline /> |
|
</div> |
|
) : ( |
|
<Avatar className="size-8 shrink-0" key={pubkey}> |
|
<AvatarImage src={avatar || defaultAvatar} className="object-cover object-center" /> |
|
<AvatarFallback delayMs={0}> |
|
<AvatarIdenticon src={defaultAvatar} /> |
|
</AvatarFallback> |
|
</Avatar> |
|
)} |
|
<span className="truncate max-xl:hidden">{username}</span> |
|
</Button> |
|
</DropdownMenuTrigger> |
|
<DropdownMenuContent side="top" align="end" className="z-[220]"> |
|
<AccountDropdownItems |
|
onSwitchAccount={onSwitchAccount} |
|
onLogoutClick={onLogoutClick} |
|
onBrowseCache={onBrowseCache} |
|
onCloseMenu={() => setMenuOpen(false)} |
|
/> |
|
</DropdownMenuContent> |
|
</DropdownMenu> |
|
) |
|
} |
|
|
|
function TitlebarAccountMenu({ |
|
onSwitchAccount, |
|
onLogoutClick, |
|
onBrowseCache |
|
}: { |
|
onSwitchAccount: () => void |
|
onLogoutClick: () => void |
|
onBrowseCache: () => void |
|
}) { |
|
const { t } = useTranslation() |
|
const { account, profile } = useNostr() |
|
const pubkey = account?.pubkey |
|
const { profile: fetchedProfile } = useFetchProfile(pubkey) |
|
const resolvedProfile = useMemo( |
|
() => profileForActivePubkey(pubkey, profile, fetchedProfile), |
|
[pubkey, profile, fetchedProfile] |
|
) |
|
const { current, display } = usePrimaryPage() |
|
const [menuOpen, setMenuOpen] = useState(false) |
|
const defaultAvatar = useMemo( |
|
() => (resolvedProfile?.pubkey ? generateImageByPubkey(resolvedProfile.pubkey) : ''), |
|
[resolvedProfile] |
|
) |
|
const active = useMemo(() => current === 'profile' && display, [display, current]) |
|
|
|
return ( |
|
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> |
|
<DropdownMenuTrigger asChild> |
|
<Button |
|
variant="ghost" |
|
size="titlebar-icon" |
|
className={cn(active ? 'bg-accent/50' : '')} |
|
title={t('Account menu')} |
|
aria-label={t('Account menu')} |
|
> |
|
{resolvedProfile ? ( |
|
isVideo(resolvedProfile.avatar ?? '') ? ( |
|
<div className={cn('w-6 h-6 overflow-hidden rounded-full', active ? 'ring-primary ring-1' : '')}> |
|
<video src={resolvedProfile.avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline /> |
|
</div> |
|
) : ( |
|
<Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')} key={pubkey}> |
|
<AvatarImage |
|
src={resolvedProfile.avatar || defaultAvatar} |
|
className="object-cover object-center" |
|
/> |
|
<AvatarFallback delayMs={0}> |
|
<AvatarIdenticon src={defaultAvatar} /> |
|
</AvatarFallback> |
|
</Avatar> |
|
) |
|
) : ( |
|
<Skeleton className={cn('w-6 h-6 rounded-full', active ? 'ring-primary ring-1' : '')} /> |
|
)} |
|
</Button> |
|
</DropdownMenuTrigger> |
|
<DropdownMenuContent align="end" side="bottom" className={titlebarAccountMenuContentClassName}> |
|
<AccountDropdownItems |
|
onSwitchAccount={onSwitchAccount} |
|
onLogoutClick={onLogoutClick} |
|
onBrowseCache={onBrowseCache} |
|
onCloseMenu={() => setMenuOpen(false)} |
|
/> |
|
</DropdownMenuContent> |
|
</DropdownMenu> |
|
) |
|
} |
|
|
|
function LoggedOutTitlebarMenu({ onLogin }: { onLogin: () => void }) { |
|
const { t } = useTranslation() |
|
|
|
return ( |
|
<Button variant="ghost" size="titlebar-icon" onClick={onLogin} title={t('Login')}> |
|
<UserRound /> |
|
</Button> |
|
) |
|
} |
|
|
|
/** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */ |
|
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { |
|
const { pubkey, checkLogin, isNip07LoginInFlight } = useNostr() |
|
const { navigateToSettings } = useSmartSettingsNavigation() |
|
const onBrowseCache = useCallback(() => { |
|
if (!openBrowseCacheFromRegistry()) { |
|
navigateToSettings(toCacheSettings()) |
|
} |
|
}, [navigateToSettings]) |
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false) |
|
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) |
|
|
|
let account: ReactNode |
|
if (pubkey) { |
|
account = |
|
variant === 'sidebar' ? ( |
|
<SidebarAccountMenu |
|
onSwitchAccount={() => setLoginDialogOpen(true)} |
|
onLogoutClick={() => setLogoutDialogOpen(true)} |
|
onBrowseCache={onBrowseCache} |
|
/> |
|
) : ( |
|
<TitlebarAccountMenu |
|
onSwitchAccount={() => setLoginDialogOpen(true)} |
|
onLogoutClick={() => setLogoutDialogOpen(true)} |
|
onBrowseCache={onBrowseCache} |
|
/> |
|
) |
|
} else if (variant === 'titlebar') { |
|
account = <LoggedOutTitlebarMenu onLogin={() => checkLogin()} /> |
|
} else { |
|
account = ( |
|
<SidebarItem onClick={() => checkLogin()} title="Login"> |
|
<LogIn strokeWidth={3} /> |
|
</SidebarItem> |
|
) |
|
} |
|
|
|
const wrapClass = |
|
variant === 'titlebar' ? 'flex shrink-0 items-center gap-1' : 'flex flex-col space-y-2' |
|
|
|
return ( |
|
<> |
|
<div className={wrapClass}> |
|
{account} |
|
</div> |
|
<LoginDialog |
|
open={loginDialogOpen} |
|
setOpen={setLoginDialogOpen} |
|
blockClose={isNip07LoginInFlight} |
|
/> |
|
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} /> |
|
</> |
|
) |
|
}
|
|
|