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 { isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { TAccountPointer, TSignerType } from '@/types'
import { TAccountPointer } from '@/types'
import { Loader, Trash2 } from 'lucide-react'
import { useState } from 'react'
import SignerTypeBadge from '../SignerTypeBadge'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
@@ -74,17 +74,3 @@ export default function AccountList({
</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,36 +1,57 @@
import { Skeleton } from '@/components/ui/skeleton'
import { LONG_PRESS_THRESHOLD } from '@/constants'
import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react'
import { useMemo } from 'react'
import { useMemo, useRef, useState } from 'react'
import LoginDialog from '../LoginDialog'
import { SimpleUserAvatar } from '../UserAvatar'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function AccountButton() {
const { navigate, current, display } = usePrimaryPage()
const { pubkey, profile } = useNostr()
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
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 (
<BottomNavigationBarItem
onClick={() => {
navigate('me')
}}
active={active}
>
{pubkey ? (
profile ? (
<SimpleUserAvatar
userId={pubkey}
className={cn('w-7 h-7', active ? 'ring-primary ring-1' : '')}
/>
<>
<BottomNavigationBarItem
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
active={active}
>
{pubkey ? (
profile ? (
<SimpleUserAvatar
userId={pubkey}
className={cn('w-7 h-7', active ? 'ring-primary ring-1' : '')}
/>
) : (
<Skeleton className={cn('w-7 h-7 rounded-full', active ? 'ring-primary ring-1' : '')} />
)
) : (
<Skeleton className={cn('w-7 h-7 rounded-full', active ? 'ring-primary ring-1' : '')} />
)
) : (
<UserRound />
)}
</BottomNavigationBarItem>
<UserRound />
)}
</BottomNavigationBarItem>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
</>
)
}

View File

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

View File

@@ -1,3 +1,4 @@
import { LONG_PRESS_THRESHOLD } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { getLightningAddressFromProfile } from '@/lib/lightning'
import { cn } from '@/lib/utils'
@@ -86,7 +87,7 @@ export default function ZapButton({ event }: { event: Event }) {
setOpenZapDialog(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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { toWallet } from '@/lib/link'
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import { useSecondaryPage } from '@/PageManager'
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 { useTranslation } from 'react-i18next'
import LoginDialog from '../LoginDialog'
import LogoutDialog from '../LogoutDialog'
import SignerTypeBadge from '../SignerTypeBadge'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
import SidebarItem from './SidebarItem'
export default function AccountButton({ collapse }: { collapse: boolean }) {
@@ -31,17 +33,13 @@ export default function AccountButton({ collapse }: { collapse: boolean }) {
function ProfileButton({ collapse }: { collapse: boolean }) {
const { t } = useTranslation()
const { account, profile } = useNostr()
const { account, accounts, switchAccount } = useNostr()
const pubkey = account?.pubkey
const { navigate } = usePrimaryPage()
const { push } = useSecondaryPage()
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
if (!pubkey) return null
const defaultAvatar = generateImageByPubkey(pubkey)
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -53,37 +51,68 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
)}
>
<div className="flex gap-2 items-center flex-1 w-0">
<Avatar className="w-8 h-8">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
{!collapse && <div className="truncate font-semibold text-lg">{username}</div>}
<SimpleUserAvatar size="medium" userId={pubkey} />
{!collapse && (
<SimpleUsername className="truncate font-semibold text-lg" userId={pubkey} />
)}
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top">
<DropdownMenuItem onClick={() => navigate('profile')}>
<UserRound />
{t('Profile')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuContent side="top" className="w-72">
<DropdownMenuItem onClick={() => push(toWallet())}>
<Wallet />
{t('Wallet')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
<ArrowDownUp />
{t('Switch account')}
<DropdownMenuLabel>{t('Switch account')}</DropdownMenuLabel>
{accounts.map((act) => (
<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
className="text-destructive focus:text-destructive"
onClick={() => setLogoutDialogOpen(true)}
>
<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>
</DropdownMenuContent>
<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
withoutSkeleton?: boolean
}) {
const { profile } = useFetchProfile(userId)
if (!profile && !withoutSkeleton) {
const { profile, isFetching } = useFetchProfile(userId)
if (!profile && isFetching && !withoutSkeleton) {
return (
<div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} />
@@ -63,8 +63,8 @@ export function SimpleUsername({
skeletonClassName?: string
withoutSkeleton?: boolean
}) {
const { profile } = useFetchProfile(userId)
if (!profile && !withoutSkeleton) {
const { profile, isFetching } = useFetchProfile(userId)
if (!profile && isFetching && !withoutSkeleton) {
return (
<div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} />

View File

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

View File

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

View File

@@ -494,6 +494,10 @@ export default {
Layout: 'Layout',
'Two-column': 'Zweispaltig',
'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',
'Two-column': 'Two-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',
'Two-column': 'Doble columna',
'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: 'چیدمان',
'Two-column': 'دو ستونی',
'Single-column': 'تک ستونی',
Reviews: 'نقدها'
Reviews: 'نقدها',
Extension: 'افزونه',
Remote: 'از راه دور',
'Encrypted Key': 'رمزگذاری شده کلید',
'Private Key': 'کلید خصوصی'
}
}

View File

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

View File

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

View File

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

View File

@@ -488,6 +488,10 @@ export default {
Layout: 'Układ',
'Two-column': 'Dwie kolumny',
'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',
'Two-column': 'Coluna dupla',
'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',
'Two-column': 'Coluna dupla',
'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: 'Макет',
'Two-column': 'Две колонки',
'Single-column': 'Одна колонка',
Reviews: 'Отзывы'
Reviews: 'Отзывы',
Extension: 'Расширение',
Remote: 'Удалённый',
'Encrypted Key': 'Зашифрованный ключ',
'Private Key': 'Приватный ключ'
}
}

View File

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

View File

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