feat: multi accounts
This commit is contained in:
@@ -11,19 +11,22 @@ import { toProfile } from '@/lib/link'
|
|||||||
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
|
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import LoginDialog from '../LoginDialog'
|
||||||
|
|
||||||
export default function ProfileButton({
|
export default function ProfileButton({
|
||||||
pubkey,
|
|
||||||
variant = 'titlebar'
|
variant = 'titlebar'
|
||||||
}: {
|
}: {
|
||||||
pubkey: string
|
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { logout } = useNostr()
|
const { removeAccount, account } = useNostr()
|
||||||
|
const pubkey = account?.pubkey
|
||||||
const { profile } = useFetchProfile(pubkey)
|
const { profile } = useFetchProfile(pubkey)
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
||||||
|
if (!pubkey) return null
|
||||||
|
|
||||||
const defaultAvatar = generateImageByPubkey(pubkey)
|
const defaultAvatar = generateImageByPubkey(pubkey)
|
||||||
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
|
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
|
||||||
@@ -72,10 +75,17 @@ export default function ProfileButton({
|
|||||||
<DropdownMenuTrigger asChild>{triggerComponent}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{triggerComponent}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={logout}>
|
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
|
||||||
|
{t('Manage accounts')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => removeAccount(account)}
|
||||||
|
>
|
||||||
{t('Logout')}
|
{t('Logout')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function AccountButton({
|
|||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
return <ProfileButton variant={variant} pubkey={pubkey} />
|
return <ProfileButton variant={variant} />
|
||||||
} else {
|
} else {
|
||||||
return <LoginButton variant={variant} />
|
return <LoginButton variant={variant} />
|
||||||
}
|
}
|
||||||
|
|||||||
76
src/components/AccountList/index.tsx
Normal file
76
src/components/AccountList/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
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 { Loader, Trash2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
|
import { SimpleUsername } from '../Username'
|
||||||
|
|
||||||
|
export default function AccountList({ afterSwitch }: { afterSwitch: () => void }) {
|
||||||
|
const { accounts, account, switchAccount, removeAccount } = useNostr()
|
||||||
|
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{accounts.map((act) => (
|
||||||
|
<div
|
||||||
|
key={`${act.pubkey}-${act.signerType}`}
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-lg',
|
||||||
|
isSameAccount(act, account)
|
||||||
|
? 'border border-primary'
|
||||||
|
: 'cursor-pointer hover:bg-muted/60'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (isSameAccount(act, account)) return
|
||||||
|
setSwitchingAccount(act)
|
||||||
|
switchAccount(act)
|
||||||
|
.then(() => afterSwitch())
|
||||||
|
.finally(() => setSwitchingAccount(null))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center p-2">
|
||||||
|
<div className="flex items-center gap-2 relative">
|
||||||
|
<SimpleUserAvatar userId={act.pubkey} />
|
||||||
|
<div>
|
||||||
|
<SimpleUsername userId={act.pubkey} className="font-semibold" />
|
||||||
|
<div className="text-sm rounded-full bg-muted px-2 w-fit">
|
||||||
|
{formatPubkey(act.pubkey)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<SignerTypeBadge signerType={act.signerType} />
|
||||||
|
<Trash2
|
||||||
|
size={16}
|
||||||
|
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
removeAccount(act)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{switchingAccount && isSameAccount(act, switchingAccount) && (
|
||||||
|
<div className="absolute top-0 left-0 flex w-full h-full items-center justify-center rounded-lg bg-muted/60">
|
||||||
|
<Loader size={16} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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 {
|
||||||
|
return <Badge>NSEC</Badge>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,13 @@ import { Loader } from 'lucide-react'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function BunkerLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) {
|
export default function BunkerLogin({
|
||||||
|
back,
|
||||||
|
onLoginSuccess
|
||||||
|
}: {
|
||||||
|
back: () => void
|
||||||
|
onLoginSuccess: () => void
|
||||||
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { bunkerLogin } = useNostr()
|
const { bunkerLogin } = useNostr()
|
||||||
const [pending, setPending] = useState(false)
|
const [pending, setPending] = useState(false)
|
||||||
@@ -42,6 +48,9 @@ export default function BunkerLogin({ onLoginSuccess }: { onLoginSuccess: () =>
|
|||||||
<Loader className={pending ? 'animate-spin' : 'hidden'} />
|
<Loader className={pending ? 'animate-spin' : 'hidden'} />
|
||||||
{t('Login')}
|
{t('Login')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={back}>
|
||||||
|
{t('Back')}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,13 @@ import { useNostr } from '@/providers/NostrProvider'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function PrivateKeyLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) {
|
export default function PrivateKeyLogin({
|
||||||
|
back,
|
||||||
|
onLoginSuccess
|
||||||
|
}: {
|
||||||
|
back: () => void
|
||||||
|
onLoginSuccess: () => void
|
||||||
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { nsecLogin } = useNostr()
|
const { nsecLogin } = useNostr()
|
||||||
const [nsec, setNsec] = useState('')
|
const [nsec, setNsec] = useState('')
|
||||||
@@ -43,6 +49,9 @@ export default function PrivateKeyLogin({ onLoginSuccess }: { onLoginSuccess: ()
|
|||||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleLogin}>{t('Login')}</Button>
|
<Button onClick={handleLogin}>{t('Login')}</Button>
|
||||||
|
<Button variant="secondary" onClick={back}>
|
||||||
|
{t('Back')}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
64
src/components/AccountManager/index.tsx
Normal file
64
src/components/AccountManager/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { TSignerType } from '@/types'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import AccountList from '../AccountList'
|
||||||
|
import BunkerLogin from './BunkerLogin'
|
||||||
|
import PrivateKeyLogin from './NsecLogin'
|
||||||
|
|
||||||
|
export default function AccountManager({ close }: { close: () => void }) {
|
||||||
|
const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{loginMethod === 'nsec' ? (
|
||||||
|
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
||||||
|
) : loginMethod === 'bunker' ? (
|
||||||
|
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
||||||
|
) : (
|
||||||
|
<AccountManagerNav setLoginMethod={setLoginMethod} close={close} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountManagerNav({
|
||||||
|
setLoginMethod,
|
||||||
|
close
|
||||||
|
}: {
|
||||||
|
setLoginMethod: (method: TSignerType) => void
|
||||||
|
close: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { nip07Login, accounts } = useNostr()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||||
|
{t('Add an Account')}
|
||||||
|
</div>
|
||||||
|
{!!window.nostr && (
|
||||||
|
<Button onClick={() => nip07Login().then(() => close())} className="w-full">
|
||||||
|
{t('Login with Browser Extension')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
|
||||||
|
{t('Login with Bunker')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
|
||||||
|
{t('Login with Private Key')}
|
||||||
|
</Button>
|
||||||
|
{accounts.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||||
|
{t('Logged in Accounts')}
|
||||||
|
</div>
|
||||||
|
<AccountList afterSwitch={() => close()} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -6,12 +5,8 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle
|
DialogTitle
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { Dispatch } from 'react'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import AccountManager from '../AccountManager'
|
||||||
import { Dispatch, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import BunkerLogin from './BunkerLogin'
|
|
||||||
import PrivateKeyLogin from './NsecLogin'
|
|
||||||
|
|
||||||
export default function LoginDialog({
|
export default function LoginDialog({
|
||||||
open,
|
open,
|
||||||
@@ -20,10 +15,6 @@ export default function LoginDialog({
|
|||||||
open: boolean
|
open: boolean
|
||||||
setOpen: Dispatch<boolean>
|
setOpen: Dispatch<boolean>
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
|
||||||
const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | 'bunker' | null>(null)
|
|
||||||
const { nip07Login } = useNostr()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="w-96">
|
<DialogContent className="w-96">
|
||||||
@@ -31,41 +22,7 @@ export default function LoginDialog({
|
|||||||
<DialogTitle className="hidden" />
|
<DialogTitle className="hidden" />
|
||||||
<DialogDescription className="hidden" />
|
<DialogDescription className="hidden" />
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{loginMethod === 'nsec' ? (
|
<AccountManager close={() => setOpen(false)} />
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
|
|
||||||
onClick={() => setLoginMethod(null)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<PrivateKeyLogin onLoginSuccess={() => setOpen(false)} />
|
|
||||||
</>
|
|
||||||
) : loginMethod === 'bunker' ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
|
|
||||||
onClick={() => setLoginMethod(null)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<BunkerLogin onLoginSuccess={() => setOpen(false)} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!!window.nostr && (
|
|
||||||
<Button onClick={() => nip07Login().then(() => setOpen(false))} className="w-full">
|
|
||||||
{t('Login with Browser Extension')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
|
|
||||||
{t('Login with Bunker')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
|
|
||||||
{t('Login with Private Key')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,3 +54,35 @@ export default function UserAvatar({
|
|||||||
</HoverCard>
|
</HoverCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SimpleUserAvatar({
|
||||||
|
userId,
|
||||||
|
size = 'normal',
|
||||||
|
className,
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
userId: string
|
||||||
|
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||||
|
className?: string
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
||||||
|
}) {
|
||||||
|
const { profile } = useFetchProfile(userId)
|
||||||
|
const defaultAvatar = useMemo(
|
||||||
|
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
|
||||||
|
[profile]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||||
|
}
|
||||||
|
const { avatar, pubkey } = profile
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar className={cn(UserAvatarSizeCnMap[size], className)} onClick={onClick}>
|
||||||
|
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||||
|
<AvatarFallback>
|
||||||
|
<img src={defaultAvatar} alt={pubkey} />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,3 +42,27 @@ export default function Username({
|
|||||||
</HoverCard>
|
</HoverCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SimpleUsername({
|
||||||
|
userId,
|
||||||
|
showAt = false,
|
||||||
|
className,
|
||||||
|
skeletonClassName
|
||||||
|
}: {
|
||||||
|
userId: string
|
||||||
|
showAt?: boolean
|
||||||
|
className?: string
|
||||||
|
skeletonClassName?: string
|
||||||
|
}) {
|
||||||
|
const { profile } = useFetchProfile(userId)
|
||||||
|
if (!profile) return <Skeleton className={cn('w-16 my-1', skeletonClassName)} />
|
||||||
|
|
||||||
|
const { username } = profile
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('max-w-fit', className)}>
|
||||||
|
{showAt && '@'}
|
||||||
|
{username}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
33
src/components/ui/badge.tsx
Normal file
33
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md border px-2.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
export const StorageKey = {
|
export const StorageKey = {
|
||||||
THEME_SETTING: 'themeSetting',
|
THEME_SETTING: 'themeSetting',
|
||||||
RELAY_GROUPS: 'relayGroups',
|
RELAY_GROUPS: 'relayGroups',
|
||||||
ACCOUNTS: 'accounts'
|
ACCOUNTS: 'accounts',
|
||||||
|
CURRENT_ACCOUNT: 'currentAccount'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BIG_RELAY_URLS = [
|
export const BIG_RELAY_URLS = [
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export default {
|
|||||||
'Login with Browser Extension': 'Login with Browser Extension',
|
'Login with Browser Extension': 'Login with Browser Extension',
|
||||||
'Login with Bunker': 'Login with Bunker',
|
'Login with Bunker': 'Login with Bunker',
|
||||||
'Login with Private Key': 'Login with Private Key',
|
'Login with Private Key': 'Login with Private Key',
|
||||||
'reload notes': 'reload notes'
|
'reload notes': 'reload notes',
|
||||||
|
'Logged in Accounts': 'Logged in Accounts',
|
||||||
|
'Add an Account': 'Add an Account',
|
||||||
|
'Manage accounts': 'Manage accounts'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export default {
|
|||||||
'Login with Browser Extension': '浏览器插件登录',
|
'Login with Browser Extension': '浏览器插件登录',
|
||||||
'Login with Bunker': 'Bunker 登录',
|
'Login with Bunker': 'Bunker 登录',
|
||||||
'Login with Private Key': '私钥登录',
|
'Login with Private Key': '私钥登录',
|
||||||
'reload notes': '重新加载笔记'
|
'reload notes': '重新加载笔记',
|
||||||
|
'Logged in Accounts': '已登录账户',
|
||||||
|
'Add an Account': '添加账户',
|
||||||
|
'Manage accounts': '多帐户管理'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/lib/account.ts
Normal file
5
src/lib/account.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { TAccountPointer } from '@/types'
|
||||||
|
|
||||||
|
export function isSameAccount(a: TAccountPointer | null, b: TAccountPointer | null) {
|
||||||
|
return a?.pubkey === b?.pubkey && a?.signerType === b?.signerType
|
||||||
|
}
|
||||||
@@ -3,22 +3,24 @@ import { useToast } from '@/hooks'
|
|||||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import storage from '@/services/storage.service'
|
import storage from '@/services/storage.service'
|
||||||
import { ISigner, TDraftEvent } from '@/types'
|
import { ISigner, TAccount, TAccountPointer, TDraftEvent } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
import { useRelaySettings } from '../RelaySettingsProvider'
|
import { useRelaySettings } from '../RelaySettingsProvider'
|
||||||
import { NsecSigner } from './nsec.signer'
|
|
||||||
import { BunkerSigner } from './bunker.signer'
|
import { BunkerSigner } from './bunker.signer'
|
||||||
import { Nip07Signer } from './nip-07.signer'
|
import { Nip07Signer } from './nip-07.signer'
|
||||||
|
import { NsecSigner } from './nsec.signer'
|
||||||
|
|
||||||
type TNostrContext = {
|
type TNostrContext = {
|
||||||
pubkey: string | null
|
pubkey: string | null
|
||||||
setPubkey: (pubkey: string) => void
|
account: TAccountPointer | null
|
||||||
|
accounts: TAccountPointer[]
|
||||||
|
switchAccount: (account: TAccountPointer | null) => Promise<void>
|
||||||
nsecLogin: (nsec: string) => Promise<string>
|
nsecLogin: (nsec: string) => Promise<string>
|
||||||
nip07Login: () => Promise<string>
|
nip07Login: () => Promise<string>
|
||||||
bunkerLogin: (bunker: string) => Promise<string>
|
bunkerLogin: (bunker: string) => Promise<string>
|
||||||
logout: () => void
|
removeAccount: (account: TAccountPointer) => void
|
||||||
/**
|
/**
|
||||||
* Default publish the event to current relays, user's write relays and additional relays
|
* Default publish the event to current relays, user's write relays and additional relays
|
||||||
*/
|
*/
|
||||||
@@ -40,83 +42,61 @@ export const useNostr = () => {
|
|||||||
|
|
||||||
export function NostrProvider({ children }: { children: React.ReactNode }) {
|
export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [pubkey, setPubkey] = useState<string | null>(null)
|
const [account, setAccount] = useState<TAccountPointer | null>(null)
|
||||||
const [signer, setSigner] = useState<ISigner | null>(null)
|
const [signer, setSigner] = useState<ISigner | null>(null)
|
||||||
const [openLoginDialog, setOpenLoginDialog] = useState(false)
|
const [openLoginDialog, setOpenLoginDialog] = useState(false)
|
||||||
const { relayUrls: currentRelayUrls } = useRelaySettings()
|
const { relayUrls: currentRelayUrls } = useRelaySettings()
|
||||||
const relayList = useFetchRelayList(pubkey)
|
const relayList = useFetchRelayList(account?.pubkey)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const [account] = storage.getAccounts()
|
const accounts = storage.getAccounts()
|
||||||
if (!account) {
|
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
|
||||||
if (!window.nostr) {
|
if (!act) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For browser env, attempt to login with nip-07
|
setAccount({ pubkey: act.pubkey, signerType: act.signerType })
|
||||||
const nip07Signer = new Nip07Signer()
|
|
||||||
const pubkey = await nip07Signer.getPublicKey()
|
const pubkey = await loginWithAccountPointer(act)
|
||||||
|
// login failed, set account to null
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
|
setAccount(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
storage.setAccounts([{ pubkey, signerType: 'nip-07' }])
|
|
||||||
return login(nip07Signer, pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.pubkey) {
|
|
||||||
setPubkey(account.pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// browser-nsec is deprecated
|
|
||||||
if (account.signerType === 'browser-nsec') {
|
|
||||||
if (account.nsec) {
|
|
||||||
const browserNsecSigner = new NsecSigner()
|
|
||||||
const pubkey = browserNsecSigner.login(account.nsec)
|
|
||||||
storage.setAccounts([{ pubkey, signerType: 'nsec', nsec: account.nsec }])
|
|
||||||
return login(browserNsecSigner, pubkey)
|
|
||||||
}
|
|
||||||
} else if (account.signerType === 'nsec') {
|
|
||||||
if (account.nsec) {
|
|
||||||
const browserNsecSigner = new NsecSigner()
|
|
||||||
const pubkey = browserNsecSigner.login(account.nsec)
|
|
||||||
return login(browserNsecSigner, pubkey)
|
|
||||||
}
|
|
||||||
} else if (account.signerType === 'nip-07') {
|
|
||||||
const nip07Signer = new Nip07Signer()
|
|
||||||
return login(nip07Signer, account.pubkey)
|
|
||||||
} else if (account.signerType === 'bunker') {
|
|
||||||
if (account.bunker && account.bunkerClientSecretKey) {
|
|
||||||
const bunkerSigner = new BunkerSigner(account.bunkerClientSecretKey)
|
|
||||||
const pubkey = await bunkerSigner.login(account.bunker)
|
|
||||||
return login(bunkerSigner, pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return logout()
|
|
||||||
}
|
}
|
||||||
init().catch(() => {
|
init().catch(() => {
|
||||||
logout()
|
setAccount(null)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = (signer: ISigner, pubkey: string) => {
|
const login = (signer: ISigner, act: TAccount) => {
|
||||||
setPubkey(pubkey)
|
storage.addAccount(act)
|
||||||
|
storage.switchAccount(act)
|
||||||
|
setAccount({ pubkey: act.pubkey, signerType: act.signerType })
|
||||||
setSigner(signer)
|
setSigner(signer)
|
||||||
return pubkey
|
return act.pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const removeAccount = (act: TAccountPointer) => {
|
||||||
setPubkey(null)
|
storage.removeAccount(act)
|
||||||
|
if (account?.pubkey === act.pubkey) {
|
||||||
|
setAccount(null)
|
||||||
setSigner(null)
|
setSigner(null)
|
||||||
storage.setAccounts([])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchAccount = async (act: TAccountPointer | null) => {
|
||||||
|
if (!act) {
|
||||||
|
storage.switchAccount(null)
|
||||||
|
setAccount(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loginWithAccountPointer(act)
|
||||||
}
|
}
|
||||||
|
|
||||||
const nsecLogin = async (nsec: string) => {
|
const nsecLogin = async (nsec: string) => {
|
||||||
const browserNsecSigner = new NsecSigner()
|
const browserNsecSigner = new NsecSigner()
|
||||||
const pubkey = browserNsecSigner.login(nsec)
|
const pubkey = browserNsecSigner.login(nsec)
|
||||||
storage.setAccounts([{ pubkey, signerType: 'nsec', nsec }])
|
return login(browserNsecSigner, { pubkey, signerType: 'nsec', nsec })
|
||||||
return login(browserNsecSigner, pubkey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nip07Login = async () => {
|
const nip07Login = async () => {
|
||||||
@@ -126,8 +106,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
throw new Error('You did not allow to access your pubkey')
|
throw new Error('You did not allow to access your pubkey')
|
||||||
}
|
}
|
||||||
storage.setAccounts([{ pubkey, signerType: 'nip-07' }])
|
return login(nip07Signer, { pubkey, signerType: 'nip-07' })
|
||||||
return login(nip07Signer, pubkey)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Login failed',
|
title: 'Login failed',
|
||||||
@@ -146,15 +125,62 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
const bunkerUrl = new URL(bunker)
|
const bunkerUrl = new URL(bunker)
|
||||||
bunkerUrl.searchParams.delete('secret')
|
bunkerUrl.searchParams.delete('secret')
|
||||||
storage.setAccounts([
|
return login(bunkerSigner, {
|
||||||
{
|
|
||||||
pubkey,
|
pubkey,
|
||||||
signerType: 'bunker',
|
signerType: 'bunker',
|
||||||
bunker: bunkerUrl.toString(),
|
bunker: bunkerUrl.toString(),
|
||||||
bunkerClientSecretKey: bunkerSigner.getClientSecretKey()
|
bunkerClientSecretKey: bunkerSigner.getClientSecretKey()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
])
|
|
||||||
return login(bunkerSigner, pubkey)
|
const loginWithAccountPointer = async (act: TAccountPointer): Promise<string | null> => {
|
||||||
|
let account = storage.findAccount(act)
|
||||||
|
if (!account) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (account.signerType === 'nsec' || account.signerType === 'browser-nsec') {
|
||||||
|
if (account.nsec) {
|
||||||
|
const browserNsecSigner = new NsecSigner()
|
||||||
|
browserNsecSigner.login(account.nsec)
|
||||||
|
// Migrate to nsec
|
||||||
|
if (account.signerType === 'browser-nsec') {
|
||||||
|
storage.removeAccount(account)
|
||||||
|
account = { ...account, signerType: 'nsec' }
|
||||||
|
storage.addAccount(account)
|
||||||
|
}
|
||||||
|
return login(browserNsecSigner, account)
|
||||||
|
}
|
||||||
|
} else if (account.signerType === 'nip-07') {
|
||||||
|
const nip07Signer = new Nip07Signer()
|
||||||
|
const pubkey = await nip07Signer.getPublicKey()
|
||||||
|
if (!pubkey) {
|
||||||
|
storage.removeAccount(account)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (pubkey !== account.pubkey) {
|
||||||
|
storage.removeAccount(account)
|
||||||
|
account = { ...account, pubkey }
|
||||||
|
storage.addAccount(account)
|
||||||
|
}
|
||||||
|
return login(nip07Signer, account)
|
||||||
|
} else if (account.signerType === 'bunker') {
|
||||||
|
if (account.bunker && account.bunkerClientSecretKey) {
|
||||||
|
const bunkerSigner = new BunkerSigner(account.bunkerClientSecretKey)
|
||||||
|
const pubkey = await bunkerSigner.login(account.bunker)
|
||||||
|
if (!pubkey) {
|
||||||
|
storage.removeAccount(account)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (pubkey !== account.pubkey) {
|
||||||
|
storage.removeAccount(account)
|
||||||
|
account = { ...account, pubkey }
|
||||||
|
storage.addAccount(account)
|
||||||
|
}
|
||||||
|
return login(bunkerSigner, account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storage.removeAccount(account)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const signEvent = async (draftEvent: TDraftEvent) => {
|
const signEvent = async (draftEvent: TDraftEvent) => {
|
||||||
@@ -197,12 +223,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<NostrContext.Provider
|
<NostrContext.Provider
|
||||||
value={{
|
value={{
|
||||||
pubkey,
|
pubkey: account?.pubkey ?? null,
|
||||||
setPubkey,
|
account,
|
||||||
|
accounts: storage
|
||||||
|
.getAccounts()
|
||||||
|
.map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })),
|
||||||
|
switchAccount,
|
||||||
nsecLogin,
|
nsecLogin,
|
||||||
nip07Login,
|
nip07Login,
|
||||||
bunkerLogin,
|
bunkerLogin,
|
||||||
logout,
|
removeAccount,
|
||||||
publish,
|
publish,
|
||||||
signHttpAuth,
|
signHttpAuth,
|
||||||
checkLogin,
|
checkLogin,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StorageKey } from '@/constants'
|
import { StorageKey } from '@/constants'
|
||||||
import { TAccount, TRelayGroup, TThemeSetting } from '@/types'
|
import { isSameAccount } from '@/lib/account'
|
||||||
|
import { TAccount, TRelayGroup, TAccountPointer, TThemeSetting } from '@/types'
|
||||||
|
|
||||||
const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [
|
const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [
|
||||||
{
|
{
|
||||||
@@ -15,6 +16,7 @@ class StorageService {
|
|||||||
private relayGroups: TRelayGroup[] = []
|
private relayGroups: TRelayGroup[] = []
|
||||||
private themeSetting: TThemeSetting = 'system'
|
private themeSetting: TThemeSetting = 'system'
|
||||||
private accounts: TAccount[] = []
|
private accounts: TAccount[] = []
|
||||||
|
private currentAccount: TAccount | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!StorageService.instance) {
|
if (!StorageService.instance) {
|
||||||
@@ -31,6 +33,8 @@ class StorageService {
|
|||||||
(window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system'
|
(window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system'
|
||||||
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
|
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
|
||||||
this.accounts = accountsStr ? JSON.parse(accountsStr) : []
|
this.accounts = accountsStr ? JSON.parse(accountsStr) : []
|
||||||
|
const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT)
|
||||||
|
this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
getRelayGroups() {
|
getRelayGroups() {
|
||||||
@@ -55,13 +59,38 @@ class StorageService {
|
|||||||
return this.accounts
|
return this.accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccounts(accounts: TAccount[]) {
|
findAccount(account: TAccountPointer) {
|
||||||
if (accounts === null) {
|
return this.accounts.find((act) => isSameAccount(act, account))
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNTS)
|
|
||||||
} else {
|
|
||||||
window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(accounts))
|
|
||||||
}
|
}
|
||||||
this.accounts = accounts
|
|
||||||
|
getCurrentAccount() {
|
||||||
|
return this.currentAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
addAccount(account: TAccount) {
|
||||||
|
if (this.accounts.find((act) => isSameAccount(act, account))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.accounts.push(account)
|
||||||
|
window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts))
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAccount(account: TAccount) {
|
||||||
|
this.accounts = this.accounts.filter((act) => !isSameAccount(act, account))
|
||||||
|
window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
switchAccount(account: TAccount | null) {
|
||||||
|
if (isSameAccount(this.currentAccount, account)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const act = this.accounts.find((act) => isSameAccount(act, account))
|
||||||
|
if (!act) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.currentAccount = act
|
||||||
|
window.localStorage.setItem(StorageKey.CURRENT_ACCOUNT, JSON.stringify(act))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,14 @@ export interface ISigner {
|
|||||||
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
|
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec'
|
||||||
|
|
||||||
export type TAccount = {
|
export type TAccount = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
signerType: 'nsec' | 'browser-nsec' | 'nip-07' | 'bunker'
|
signerType: TSignerType
|
||||||
nsec?: string
|
nsec?: string
|
||||||
bunker?: string
|
bunker?: string
|
||||||
bunkerClientSecretKey?: string
|
bunkerClientSecretKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
||||||
|
|||||||
Reference in New Issue
Block a user