feat: add quick account switch interaction

This commit is contained in:
codytseng
2025-10-26 16:11:21 +08:00
parent f33c5260df
commit ad6b8890c5
24 changed files with 217 additions and 85 deletions

View File

@@ -1,12 +1,12 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { isSameAccount } from '@/lib/account' import { isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TAccountPointer, TSignerType } from '@/types' import { TAccountPointer } from '@/types'
import { Loader, Trash2 } from 'lucide-react' import { Loader, Trash2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import SignerTypeBadge from '../SignerTypeBadge'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username' import { SimpleUsername } from '../Username'
@@ -74,17 +74,3 @@ export default function AccountList({
</div> </div>
) )
} }
function SignerTypeBadge({ signerType }: { signerType: TSignerType }) {
if (signerType === 'nip-07') {
return <Badge className=" bg-green-400 hover:bg-green-400/80">NIP-07</Badge>
} else if (signerType === 'bunker') {
return <Badge className=" bg-blue-400 hover:bg-blue-400/80">Bunker</Badge>
} else if (signerType === 'ncryptsec') {
return <Badge>NCRYPTSEC</Badge>
} else if (signerType === 'nsec') {
return <Badge className=" bg-orange-400 hover:bg-orange-400/80">NSEC</Badge>
} else if (signerType === 'npub') {
return <Badge className=" bg-yellow-400 hover:bg-yellow-400/80">NPUB</Badge>
}
}

View File

@@ -1,22 +1,41 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { LONG_PRESS_THRESHOLD } from '@/constants'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react' import { UserRound } from 'lucide-react'
import { useMemo } from 'react' import { useMemo, useRef, useState } from 'react'
import LoginDialog from '../LoginDialog'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import BottomNavigationBarItem from './BottomNavigationBarItem' import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function AccountButton() { export default function AccountButton() {
const { navigate, current, display } = usePrimaryPage() const { navigate, current, display } = usePrimaryPage()
const { pubkey, profile } = useNostr() const { pubkey, profile } = useNostr()
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const active = useMemo(() => current === 'me' && display, [display, current]) const active = useMemo(() => current === 'me' && display, [display, current])
const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handlePointerDown = () => {
pressTimerRef.current = setTimeout(() => {
setLoginDialogOpen(true)
pressTimerRef.current = null
}, LONG_PRESS_THRESHOLD)
}
const handlePointerUp = () => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current)
navigate('me')
pressTimerRef.current = null
}
}
return ( return (
<>
<BottomNavigationBarItem <BottomNavigationBarItem
onClick={() => { onPointerDown={handlePointerDown}
navigate('me') onPointerUp={handlePointerUp}
}}
active={active} active={active}
> >
{pubkey ? ( {pubkey ? (
@@ -32,5 +51,7 @@ export default function AccountButton() {
<UserRound /> <UserRound />
)} )}
</BottomNavigationBarItem> </BottomNavigationBarItem>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
</>
) )
} }

View File

@@ -1,15 +1,19 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
import { MouseEventHandler } from 'react' import { MouseEventHandler } from 'react'
export default function BottomNavigationBarItem({ export default function BottomNavigationBarItem({
children, children,
active = false, active = false,
onClick onClick,
onPointerDown,
onPointerUp
}: { }: {
children: React.ReactNode children: React.ReactNode
active?: boolean active?: boolean
onClick: MouseEventHandler onClick?: MouseEventHandler
onPointerDown?: MouseEventHandler
onPointerUp?: MouseEventHandler
}) { }) {
return ( return (
<Button <Button
@@ -19,6 +23,8 @@ export default function BottomNavigationBarItem({
)} )}
variant="ghost" variant="ghost"
onClick={onClick} onClick={onClick}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
> >
{children} {children}
</Button> </Button>

View File

@@ -1,3 +1,4 @@
import { LONG_PRESS_THRESHOLD } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { getLightningAddressFromProfile } from '@/lib/lightning' import { getLightningAddressFromProfile } from '@/lib/lightning'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -86,7 +87,7 @@ export default function ZapButton({ event }: { event: Event }) {
setOpenZapDialog(true) setOpenZapDialog(true)
setZapping(true) setZapping(true)
}) })
}, 500) }, LONG_PRESS_THRESHOLD)
} }
} }

View File

@@ -1,22 +1,24 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { toWallet } from '@/lib/link' import { toWallet } from '@/lib/link'
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { usePrimaryPage, useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, LogIn, LogOut, UserRound, Wallet } from 'lucide-react' import { LogIn, LogOut, Plus, Wallet } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import LoginDialog from '../LoginDialog' import LoginDialog from '../LoginDialog'
import LogoutDialog from '../LogoutDialog' import LogoutDialog from '../LogoutDialog'
import SignerTypeBadge from '../SignerTypeBadge'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
export default function AccountButton({ collapse }: { collapse: boolean }) { export default function AccountButton({ collapse }: { collapse: boolean }) {
@@ -31,17 +33,13 @@ export default function AccountButton({ collapse }: { collapse: boolean }) {
function ProfileButton({ collapse }: { collapse: boolean }) { function ProfileButton({ collapse }: { collapse: boolean }) {
const { t } = useTranslation() const { t } = useTranslation()
const { account, profile } = useNostr() const { account, accounts, switchAccount } = useNostr()
const pubkey = account?.pubkey const pubkey = account?.pubkey
const { navigate } = usePrimaryPage()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
if (!pubkey) return null if (!pubkey) return null
const defaultAvatar = generateImageByPubkey(pubkey)
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -53,37 +51,68 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
)} )}
> >
<div className="flex gap-2 items-center flex-1 w-0"> <div className="flex gap-2 items-center flex-1 w-0">
<Avatar className="w-8 h-8"> <SimpleUserAvatar size="medium" userId={pubkey} />
<AvatarImage src={avatar} /> {!collapse && (
<AvatarFallback> <SimpleUsername className="truncate font-semibold text-lg" userId={pubkey} />
<img src={defaultAvatar} /> )}
</AvatarFallback>
</Avatar>
{!collapse && <div className="truncate font-semibold text-lg">{username}</div>}
</div> </div>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top"> <DropdownMenuContent side="top" className="w-72">
<DropdownMenuItem onClick={() => navigate('profile')}>
<UserRound />
{t('Profile')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => push(toWallet())}> <DropdownMenuItem onClick={() => push(toWallet())}>
<Wallet /> <Wallet />
{t('Wallet')} {t('Wallet')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}> <DropdownMenuLabel>{t('Switch account')}</DropdownMenuLabel>
<ArrowDownUp /> {accounts.map((act) => (
{t('Switch account')} <DropdownMenuItem
className={act.pubkey === pubkey ? 'cursor-default focus:bg-background' : ''}
key={`${act.pubkey}:${act.signerType}`}
onClick={() => {
if (act.pubkey !== pubkey) {
switchAccount(act)
}
}}
>
<div className="flex gap-2 items-center flex-1">
<SimpleUserAvatar userId={act.pubkey} />
<div className="flex-1 w-0">
<SimpleUsername
userId={act.pubkey}
className="font-medium truncate"
skeletonClassName="h-3"
/>
<SignerTypeBadge signerType={act.signerType} />
</div>
</div>
<div
className={cn(
'border border-muted-foreground rounded-full size-3.5',
act.pubkey === pubkey && 'size-4 border-4 border-primary'
)}
/>
</DropdownMenuItem>
))}
<DropdownMenuItem
onClick={() => setLoginDialogOpen(true)}
className="border border-dashed m-2 focus:border-muted-foreground focus:bg-background"
>
<div className="flex gap-2 items-center justify-center w-full py-2">
<Plus />
{t('Add an Account')}
</div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onClick={() => setLogoutDialogOpen(true)} onClick={() => setLogoutDialogOpen(true)}
> >
<LogOut /> <LogOut />
{t('Logout')} <span className="shrink-0">{t('Logout')}</span>
<SimpleUsername
userId={pubkey}
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate"
/>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} /> <LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />

View File

@@ -0,0 +1,23 @@
import { Badge } from '@/components/ui/badge'
import { TSignerType } from '@/types'
import { useTranslation } from 'react-i18next'
export default function SignerTypeBadge({ signerType }: { signerType: TSignerType }) {
const { t } = useTranslation()
if (signerType === 'nip-07') {
return <Badge className=" bg-green-400 hover:bg-green-400 px-1 py-0">{t('Extension')}</Badge>
} else if (signerType === 'bunker') {
return <Badge className=" bg-blue-400 hover:bg-blue-400 px-1 py-0">{t('Remote')}</Badge>
} else if (signerType === 'ncryptsec') {
return (
<Badge className="bg-violet-400 hover:bg-violet-400 px-1 py-0">{t('Encrypted Key')}</Badge>
)
} else if (signerType === 'nsec') {
return (
<Badge className=" bg-orange-400 hover:bg-orange-400 px-1 py-0">{t('Private Key')}</Badge>
)
} else if (signerType === 'npub') {
return <Badge className=" bg-yellow-400 hover:bg-yellow-400 px-1 py-0">NPUB</Badge>
}
}

View File

@@ -19,8 +19,8 @@ export default function Username({
skeletonClassName?: string skeletonClassName?: string
withoutSkeleton?: boolean withoutSkeleton?: boolean
}) { }) {
const { profile } = useFetchProfile(userId) const { profile, isFetching } = useFetchProfile(userId)
if (!profile && !withoutSkeleton) { if (!profile && isFetching && !withoutSkeleton) {
return ( return (
<div className="py-1"> <div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} /> <Skeleton className={cn('w-16', skeletonClassName)} />
@@ -63,8 +63,8 @@ export function SimpleUsername({
skeletonClassName?: string skeletonClassName?: string
withoutSkeleton?: boolean withoutSkeleton?: boolean
}) { }) {
const { profile } = useFetchProfile(userId) const { profile, isFetching } = useFetchProfile(userId)
if (!profile && !withoutSkeleton) { if (!profile && isFetching && !withoutSkeleton) {
return ( return (
<div className="py-1"> <div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} /> <Skeleton className={cn('w-16', skeletonClassName)} />

View File

@@ -432,3 +432,5 @@ export const PRIMARY_COLORS = {
} }
} as const } as const
export type TPrimaryColor = keyof typeof PRIMARY_COLORS export type TPrimaryColor = keyof typeof PRIMARY_COLORS
export const LONG_PRESS_THRESHOLD = 500

View File

@@ -480,6 +480,10 @@ export default {
Layout: 'التخطيط', Layout: 'التخطيط',
'Two-column': 'عمودين', 'Two-column': 'عمودين',
'Single-column': 'عمود واحد', 'Single-column': 'عمود واحد',
Reviews: 'المراجعات' Reviews: 'المراجعات',
Extension: 'امتداد',
Remote: 'عن بُعد',
'Encrypted Key': 'مفتاح مشفر',
'Private Key': 'مفتاح خاص'
} }
} }

View File

@@ -494,6 +494,10 @@ export default {
Layout: 'Layout', Layout: 'Layout',
'Two-column': 'Zweispaltig', 'Two-column': 'Zweispaltig',
'Single-column': 'Einspaltig', 'Single-column': 'Einspaltig',
Reviews: 'Bewertungen' Reviews: 'Bewertungen',
Extension: 'Erweiterung',
Remote: 'Remote',
'Encrypted Key': 'Verschlüsselter Schlüssel',
'Private Key': 'Privater Schlüssel'
} }
} }

View File

@@ -479,6 +479,10 @@ export default {
Layout: 'Layout', Layout: 'Layout',
'Two-column': 'Two-column', 'Two-column': 'Two-column',
'Single-column': 'Single-column', 'Single-column': 'Single-column',
Reviews: 'Reviews' Reviews: 'Reviews',
Extension: 'Extension',
Remote: 'Remote',
'Encrypted Key': 'Encrypted Key',
'Private Key': 'Private Key'
} }
} }

View File

@@ -488,6 +488,10 @@ export default {
Layout: 'Diseño', Layout: 'Diseño',
'Two-column': 'Doble columna', 'Two-column': 'Doble columna',
'Single-column': 'Columna única', 'Single-column': 'Columna única',
Reviews: 'Reseñas' Reviews: 'Reseñas',
Extension: 'Extensión',
Remote: 'Remoto',
'Encrypted Key': 'Clave privada cifrada',
'Private Key': 'Clave privada'
} }
} }

View File

@@ -483,6 +483,10 @@ export default {
Layout: 'چیدمان', Layout: 'چیدمان',
'Two-column': 'دو ستونی', 'Two-column': 'دو ستونی',
'Single-column': 'تک ستونی', 'Single-column': 'تک ستونی',
Reviews: 'نقدها' Reviews: 'نقدها',
Extension: 'افزونه',
Remote: 'از راه دور',
'Encrypted Key': 'رمزگذاری شده کلید',
'Private Key': 'کلید خصوصی'
} }
} }

View File

@@ -493,6 +493,10 @@ export default {
Layout: 'Disposition', Layout: 'Disposition',
'Two-column': 'Deux colonnes', 'Two-column': 'Deux colonnes',
'Single-column': 'Une seule colonne', 'Single-column': 'Une seule colonne',
Reviews: 'Avis' Reviews: 'Avis',
Extension: 'Extension',
Remote: 'Distant',
'Encrypted Key': 'Clé chiffrée',
'Private Key': 'Clé privée'
} }
} }

View File

@@ -485,6 +485,10 @@ export default {
Layout: 'लेआउट', Layout: 'लेआउट',
'Two-column': 'दोहरा स्तंभ', 'Two-column': 'दोहरा स्तंभ',
'Single-column': 'एकल स्तंभ', 'Single-column': 'एकल स्तंभ',
Reviews: 'समीक्षाएं' Reviews: 'समीक्षाएं',
Extension: 'एक्सटेंशन',
Remote: 'रिमोट',
'Encrypted Key': 'एन्क्रिप्टेड की',
'Private Key': 'प्राइवेट की'
} }
} }

View File

@@ -488,6 +488,10 @@ export default {
Layout: 'Layout', Layout: 'Layout',
'Two-column': 'Doppia colonna', 'Two-column': 'Doppia colonna',
'Single-column': 'Colonna singola', 'Single-column': 'Colonna singola',
Reviews: 'Recensioni' Reviews: 'Recensioni',
Extension: 'Estensione',
Remote: 'Remoto',
'Encrypted Key': 'Chiave Crittografata',
'Private Key': 'Chiave Privata'
} }
} }

View File

@@ -484,6 +484,10 @@ export default {
Layout: 'レイアウト', Layout: 'レイアウト',
'Two-column': '2列', 'Two-column': '2列',
'Single-column': '1列', 'Single-column': '1列',
Reviews: 'レビュー' Reviews: 'レビュー',
Extension: '拡張機能',
Remote: 'リモート',
'Encrypted Key': '暗号化キー',
'Private Key': '暗号化されたキー'
} }
} }

View File

@@ -484,6 +484,10 @@ export default {
Layout: '레이아웃', Layout: '레이아웃',
'Two-column': '두 열', 'Two-column': '두 열',
'Single-column': '한 열', 'Single-column': '한 열',
Reviews: '리뷰' Reviews: '리뷰',
Extension: '확장 프로그램',
Remote: '원격',
'Encrypted Key': '암호화된 키',
'Private Key': '개인 키'
} }
} }

View File

@@ -488,6 +488,10 @@ export default {
Layout: 'Układ', Layout: 'Układ',
'Two-column': 'Dwie kolumny', 'Two-column': 'Dwie kolumny',
'Single-column': 'Jedna kolumna', 'Single-column': 'Jedna kolumna',
Reviews: 'Opinie' Reviews: 'Opinie',
Extension: 'Rozszerzenie',
Remote: 'Zdalne',
'Encrypted Key': 'Zaszyfrowany Klucz',
'Private Key': 'Zaszyfrowany Klucz'
} }
} }

View File

@@ -485,6 +485,10 @@ export default {
Layout: 'Layout', Layout: 'Layout',
'Two-column': 'Coluna dupla', 'Two-column': 'Coluna dupla',
'Single-column': 'Coluna única', 'Single-column': 'Coluna única',
Reviews: 'Avaliações' Reviews: 'Avaliações',
Extension: 'Extensão',
Remote: 'Remoto',
'Encrypted Key': 'Chave Criptografada',
'Private Key': 'Chave Privada'
} }
} }

View File

@@ -488,6 +488,10 @@ export default {
Layout: 'Layout', Layout: 'Layout',
'Two-column': 'Coluna dupla', 'Two-column': 'Coluna dupla',
'Single-column': 'Coluna única', 'Single-column': 'Coluna única',
Reviews: 'Avaliações' Reviews: 'Avaliações',
Extension: 'Extensão',
Remote: 'Remoto',
'Encrypted Key': 'Chave Criptografada',
'Private Key': 'Chave Privada'
} }
} }

View File

@@ -490,6 +490,10 @@ export default {
Layout: 'Макет', Layout: 'Макет',
'Two-column': 'Две колонки', 'Two-column': 'Две колонки',
'Single-column': 'Одна колонка', 'Single-column': 'Одна колонка',
Reviews: 'Отзывы' Reviews: 'Отзывы',
Extension: 'Расширение',
Remote: 'Удалённый',
'Encrypted Key': 'Зашифрованный ключ',
'Private Key': 'Приватный ключ'
} }
} }

View File

@@ -478,6 +478,10 @@ export default {
Layout: 'เค้าโครง', Layout: 'เค้าโครง',
'Two-column': 'สองคอลัมน์', 'Two-column': 'สองคอลัมน์',
'Single-column': 'คอลัมน์เดียว', 'Single-column': 'คอลัมน์เดียว',
Reviews: 'รีวิว' Reviews: 'รีวิว',
Extension: 'ส่วนขยาย',
Remote: 'ระยะไกล',
'Encrypted Key': 'คีย์ที่เข้ารหัส',
'Private Key': 'คีย์ส่วนตัว'
} }
} }

View File

@@ -476,6 +476,10 @@ export default {
Layout: '布局', Layout: '布局',
'Two-column': '双栏', 'Two-column': '双栏',
'Single-column': '单栏', 'Single-column': '单栏',
Reviews: '评价' Reviews: '评价',
Extension: '扩展',
Remote: '远程',
'Encrypted Key': '加密私钥',
'Private Key': '私钥'
} }
} }