Browse Source

fix refresh

imwald
Silberengel 2 weeks ago
parent
commit
cf331007a7
  1. 66
      src/components/AccountList/index.tsx
  2. 31
      src/components/AccountManager/index.tsx
  3. 93
      src/components/AccountQuickSwitchMenuItems.tsx
  4. 7
      src/components/BottomNavigationBar/WriteButton.tsx
  5. 24
      src/components/HelpAndAccountMenu.tsx
  6. 19
      src/components/LoginDialog/index.tsx
  7. 12
      src/components/ReadOnlySessionIndicator.tsx
  8. 7
      src/components/Sidebar/PostButton.tsx
  9. 116
      src/components/StoredAccountSwitchSelect.tsx
  10. 2
      src/i18n/locales/de.ts
  11. 2
      src/i18n/locales/en.ts
  12. 58
      src/lib/account.test.ts
  13. 72
      src/lib/account.ts
  14. 23
      src/lib/pubkey-nip07.test.ts
  15. 11
      src/lib/pubkey.ts
  16. 247
      src/providers/NostrProvider/index.tsx
  17. 8
      src/providers/nostr-context.tsx

66
src/components/AccountList/index.tsx

@ -1,13 +1,15 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { isSameAccount } from '@/lib/account' import { isRedundantAccountPick, isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey, hexPubkeysEqual, normalizeHexPubkey } 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, TSignerType } from '@/types'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username' import { SimpleUsername } from '../Username'
@ -23,7 +25,15 @@ export default function AccountList({
* dialogs fighting over focus trapping). */ * dialogs fighting over focus trapping). */
closeDialog?: () => void closeDialog?: () => void
}) { }) {
const { accounts, account, switchAccount, removeAccount } = useNostr() const { t } = useTranslation()
const {
accounts,
account,
switchAccount,
viewAccountAsReadOnly,
removeAccount,
retryNip07SignerForPreferredAccount
} = useNostr()
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null) const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
return ( return (
@ -33,19 +43,49 @@ export default function AccountList({
key={`${act.pubkey}-${act.signerType}`} key={`${act.pubkey}-${act.signerType}`}
className={cn( className={cn(
'relative rounded-lg', 'relative rounded-lg',
act.pubkey === account?.pubkey ? 'border border-primary' : 'clickable' account &&
hexPubkeysEqual(
normalizeHexPubkey(act.pubkey),
normalizeHexPubkey(account.pubkey)
) &&
(act.signerType === account.signerType ||
(account.signerType === 'npub' && act.signerType === 'nip-07'))
? 'border border-primary'
: 'clickable'
)} )}
onClick={() => { onClick={() => {
if (isSameAccount(act, account)) return void (async () => {
setSwitchingAccount(act) if (isRedundantAccountPick(act, account)) {
if (act.signerType === 'ncryptsec') { if (account?.signerType === 'npub' && act.signerType === 'nip-07') {
closeDialog?.() setSwitchingAccount(act)
} const ok = await retryNip07SignerForPreferredAccount()
switchAccount(act) if (ok) toast.success(t('accountSwitch.extensionConnected'))
.then(() => { else toast.error(t('accountSwitch.extensionRetryFailed'))
setSwitchingAccount(null)
}
return
}
setSwitchingAccount(act)
if (act.signerType === 'ncryptsec') {
closeDialog?.()
}
try {
const needsWriteSigner =
act.signerType === 'nsec' ||
act.signerType === 'ncryptsec' ||
act.signerType === 'bunker'
const switched = needsWriteSigner
? await switchAccount(act)
: await viewAccountAsReadOnly(act)
if (!switched) {
toast.error(t('notificationsSwitchAccountFailed'))
return
}
if (act.signerType !== 'ncryptsec') afterSwitch() if (act.signerType !== 'ncryptsec') afterSwitch()
}) } finally {
.finally(() => setSwitchingAccount(null)) setSwitchingAccount(null)
}
})()
}} }}
> >
<div className="flex justify-between items-center p-2"> <div className="flex justify-between items-center p-2">

31
src/components/AccountManager/index.tsx

@ -5,7 +5,8 @@ import { Separator } from '@/components/ui/separator'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { generateSecretKey } from 'nostr-tools' import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19' import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react' import { useState, useCallback } from 'react'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import AccountList from '../AccountList' import AccountList from '../AccountList'
@ -41,9 +42,26 @@ function AccountManagerNav({
close?: () => void close?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { nip07Login, nsecLogin, accounts } = useNostr() const { nip07Login, nsecLogin, accounts, isNip07LoginInFlight, requestAccountNetworkHydrate } =
useNostr()
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [signingUp, setSigningUp] = useState(false) const [signingUp, setSigningUp] = useState(false)
const [extensionLoginPending, setExtensionLoginPending] = useState(false)
const handleExtensionLogin = useCallback(async () => {
setExtensionLoginPending(true)
try {
const pubkey = await nip07Login()
if (pubkey) {
await requestAccountNetworkHydrate()
close?.()
}
} catch {
// nip07Login toasts and rethrows
} finally {
setExtensionLoginPending(false)
}
}, [nip07Login, close])
const handleSignUp = async () => { const handleSignUp = async () => {
setSigningUp(true) setSigningUp(true)
@ -67,7 +85,14 @@ function AccountManagerNav({
</div> </div>
<div className="space-y-2 mt-4"> <div className="space-y-2 mt-4">
{!!window.nostr && ( {!!window.nostr && (
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full"> <Button
onClick={() => void handleExtensionLogin()}
disabled={extensionLoginPending || isNip07LoginInFlight}
className="w-full"
>
{extensionLoginPending || isNip07LoginInFlight ? (
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden />
) : null}
{t('Login with Browser Extension')} {t('Login with Browser Extension')}
</Button> </Button>
)} )}

93
src/components/AccountQuickSwitchMenuItems.tsx

@ -0,0 +1,93 @@
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import {
accountPointerKey,
isRedundantAccountPick,
isSameAccountPubkey,
listSwitchableAccounts
} from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import type { TAccountPointer } from '@/types'
import { Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?: () => void }) {
const { t } = useTranslation()
const {
accounts,
account,
switchAccount,
viewAccountAsReadOnly,
retryNip07SignerForPreferredAccount
} = useNostr()
const rows = listSwitchableAccounts(accounts)
if (rows.length <= 1) return null
const handleSwitch = async (act: TAccountPointer) => {
if (isRedundantAccountPick(act, account)) {
if (account?.signerType === 'npub' && act.signerType === 'nip-07') {
const ok = await retryNip07SignerForPreferredAccount()
if (ok) {
toast.success(t('accountSwitch.extensionConnected'))
onAfterSwitch?.()
} else {
toast.error(t('accountSwitch.extensionRetryFailed'))
}
}
return
}
const needsWriteSigner =
act.signerType === 'nsec' ||
act.signerType === 'ncryptsec' ||
act.signerType === 'bunker'
const switched = needsWriteSigner
? await switchAccount(act)
: await viewAccountAsReadOnly(act)
if (!switched) {
toast.error(t('notificationsSwitchAccountFailed'))
return
}
onAfterSwitch?.()
}
return (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
{t('notificationsViewAsAccount')}
</DropdownMenuLabel>
{rows.map((act) => {
const active =
account != null &&
isSameAccountPubkey(act, account) &&
(account.signerType === act.signerType ||
(account.signerType === 'npub' && act.signerType === 'nip-07'))
return (
<DropdownMenuItem
key={accountPointerKey(act)}
className="gap-2"
onClick={() => void handleSwitch(act)}
>
<SimpleUserAvatar userId={act.pubkey} size="small" className="shrink-0" />
<span className="min-w-0 flex-1">
<SimpleUsername userId={act.pubkey} className="block truncate text-sm font-medium" />
<span className="block truncate text-xs text-muted-foreground">
{formatPubkey(act.pubkey)}
</span>
</span>
<Check className={cn('size-4 shrink-0', active ? 'opacity-100' : 'opacity-0')} aria-hidden />
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
</>
)
}

7
src/components/BottomNavigationBar/WriteButton.tsx

@ -6,16 +6,19 @@ import { useEffect, useState } from 'react'
import BottomNavigationBarItem from './BottomNavigationBarItem' import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function WriteButton() { export default function WriteButton() {
const { checkLogin } = useNostr() const { checkLogin, canSignEvents } = useNostr()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (!canSignEvents) return
const onRequest = () => { const onRequest = () => {
checkLogin(() => setOpen(true)) checkLogin(() => setOpen(true))
} }
postEditorService.addEventListener('requestOpenNewPost', onRequest) postEditorService.addEventListener('requestOpenNewPost', onRequest)
return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest) return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest)
}, [checkLogin]) }, [canSignEvents, checkLogin])
if (!canSignEvents) return null
return ( return (
<> <>

24
src/components/HelpAndAccountMenu.tsx

@ -20,6 +20,8 @@ import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartSettingsNavigation } from '@/PageManager' import { useSmartSettingsNavigation } from '@/PageManager'
import { useFetchProfile } from '@/hooks/useFetchProfile' import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { AccountQuickSwitchMenuItems } from '@/components/AccountQuickSwitchMenuItems'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
import { ActiveRelaysDropdownSection } from '@/components/ConnectedRelays/ActiveRelaysDropdownSection' import { ActiveRelaysDropdownSection } from '@/components/ConnectedRelays/ActiveRelaysDropdownSection'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows' import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react'
@ -34,17 +36,21 @@ export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar'
function AccountDropdownItems({ function AccountDropdownItems({
onSwitchAccount, onSwitchAccount,
onLogoutClick, onLogoutClick,
onBrowseCache onBrowseCache,
onCloseMenu
}: { }: {
onSwitchAccount: () => void onSwitchAccount: () => void
onLogoutClick: () => void onLogoutClick: () => void
onBrowseCache: () => void onBrowseCache: () => void
onCloseMenu?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
return ( return (
<> <>
<ReadOnlySessionIndicator variant="menu" />
<AccountQuickSwitchMenuItems onAfterSwitch={onCloseMenu} />
<DropdownMenuItem onClick={() => navigate('profile')}> <DropdownMenuItem onClick={() => navigate('profile')}>
<User className="size-4" /> <User className="size-4" />
{t('Profile')} {t('Profile')}
@ -83,6 +89,7 @@ function SidebarAccountMenu({
const { t } = useTranslation() const { t } = useTranslation()
const { account, profile } = useNostr() const { account, profile } = useNostr()
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const [menuOpen, setMenuOpen] = useState(false)
const pubkey = account?.pubkey const pubkey = account?.pubkey
const { profile: fetchedProfile } = useFetchProfile(pubkey) const { profile: fetchedProfile } = useFetchProfile(pubkey)
const active = useMemo(() => current === 'profile' && display, [display, current]) const active = useMemo(() => current === 'profile' && display, [display, current])
@ -96,7 +103,7 @@ function SidebarAccountMenu({
const { username, avatar } = resolvedProfile || { username: fallbackUsername, avatar: defaultAvatar } const { username, avatar } = resolvedProfile || { username: fallbackUsername, avatar: defaultAvatar }
return ( return (
<DropdownMenu> <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
@ -129,6 +136,7 @@ function SidebarAccountMenu({
onSwitchAccount={onSwitchAccount} onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick} onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache} onBrowseCache={onBrowseCache}
onCloseMenu={() => setMenuOpen(false)}
/> />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -150,6 +158,7 @@ function TitlebarAccountMenu({
const { profile: fetchedProfile } = useFetchProfile(pubkey) const { profile: fetchedProfile } = useFetchProfile(pubkey)
const resolvedProfile = fetchedProfile ?? profile const resolvedProfile = fetchedProfile ?? profile
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const [menuOpen, setMenuOpen] = useState(false)
const defaultAvatar = useMemo( const defaultAvatar = useMemo(
() => (resolvedProfile?.pubkey ? generateImageByPubkey(resolvedProfile.pubkey) : ''), () => (resolvedProfile?.pubkey ? generateImageByPubkey(resolvedProfile.pubkey) : ''),
[resolvedProfile] [resolvedProfile]
@ -157,7 +166,7 @@ function TitlebarAccountMenu({
const active = useMemo(() => current === 'profile' && display, [display, current]) const active = useMemo(() => current === 'profile' && display, [display, current])
return ( return (
<DropdownMenu> <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -192,6 +201,7 @@ function TitlebarAccountMenu({
onSwitchAccount={onSwitchAccount} onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick} onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache} onBrowseCache={onBrowseCache}
onCloseMenu={() => setMenuOpen(false)}
/> />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -230,7 +240,7 @@ function LoggedOutTitlebarMenu({ onLogin }: { onLogin: () => void }) {
/** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */ /** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) {
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin, isNip07LoginInFlight } = useNostr()
const { navigateToSettings } = useSmartSettingsNavigation() const { navigateToSettings } = useSmartSettingsNavigation()
const onBrowseCache = useCallback(() => { const onBrowseCache = useCallback(() => {
if (!openBrowseCacheFromRegistry()) { if (!openBrowseCacheFromRegistry()) {
@ -274,7 +284,11 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
<div className={wrapClass}> <div className={wrapClass}>
{account} {account}
</div> </div>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} /> <LoginDialog
open={loginDialogOpen}
setOpen={setLoginDialogOpen}
blockClose={isNip07LoginInFlight}
/>
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} /> <LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
</> </>
) )

19
src/components/LoginDialog/index.tsx

@ -1,21 +1,32 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Dispatch } from 'react' import { Dispatch, useCallback } from 'react'
import AccountManager from '../AccountManager' import AccountManager from '../AccountManager'
export default function LoginDialog({ export default function LoginDialog({
open, open,
setOpen setOpen,
blockClose = false
}: { }: {
open: boolean open: boolean
setOpen: Dispatch<boolean> setOpen: Dispatch<boolean>
/** Keep open while a NIP-07 extension authorize dialog is in progress. */
blockClose?: boolean
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next && blockClose) return
setOpen(next)
},
[blockClose, setOpen]
)
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer open={open} onOpenChange={setOpen}> <Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent className="max-h-[90vh]"> <DrawerContent className="max-h-[90vh]">
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Account Manager</DrawerTitle> <DrawerTitle>Account Manager</DrawerTitle>
@ -30,7 +41,7 @@ export default function LoginDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-[520px] max-h-[90vh] py-8 overflow-auto"> <DialogContent className="w-[520px] max-h-[90vh] py-8 overflow-auto">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Account Manager</DialogTitle> <DialogTitle>Account Manager</DialogTitle>

12
src/components/ReadOnlySessionIndicator.tsx

@ -1,8 +1,9 @@
import { DropdownMenuLabel } from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type TVariant = 'sidebar' | 'titlebar' type TVariant = 'sidebar' | 'titlebar' | 'menu'
export function ReadOnlySessionIndicator({ variant }: { variant: TVariant }) { export function ReadOnlySessionIndicator({ variant }: { variant: TVariant }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -11,6 +12,15 @@ export function ReadOnlySessionIndicator({ variant }: { variant: TVariant }) {
const hint = t('readOnlySession.hint') const hint = t('readOnlySession.hint')
if (variant === 'menu') {
return (
<DropdownMenuLabel className="py-1.5 text-xs font-medium text-amber-700 dark:text-amber-200">
<span title={hint}>{t('readOnlySession.label')}</span>
<span className="mt-0.5 block font-normal text-muted-foreground">{hint}</span>
</DropdownMenuLabel>
)
}
if (variant === 'sidebar') { if (variant === 'sidebar') {
return ( return (
<div <div

7
src/components/Sidebar/PostButton.tsx

@ -6,16 +6,19 @@ import { useEffect, useState } from 'react'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
export default function PostButton() { export default function PostButton() {
const { checkLogin } = useNostr() const { checkLogin, canSignEvents } = useNostr()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (!canSignEvents) return
const onRequest = () => { const onRequest = () => {
checkLogin(() => setOpen(true)) checkLogin(() => setOpen(true))
} }
postEditorService.addEventListener('requestOpenNewPost', onRequest) postEditorService.addEventListener('requestOpenNewPost', onRequest)
return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest) return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest)
}, [checkLogin]) }, [canSignEvents, checkLogin])
if (!canSignEvents) return null
return ( return (
<div className="pt-4"> <div className="pt-4">

116
src/components/StoredAccountSwitchSelect.tsx

@ -1,5 +1,6 @@
import { SimpleUserAvatar } from '@/components/UserAvatar' import { SimpleUserAvatar } from '@/components/UserAvatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { accountPointerKey, isRedundantAccountPick, listSwitchableAccounts } from '@/lib/account'
import { formatPubkey, hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { formatPubkey, hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Nip07Signer } from '@/providers/NostrProvider/nip-07.signer' import { Nip07Signer } from '@/providers/NostrProvider/nip-07.signer'
@ -23,20 +24,6 @@ type Props = {
alignEnd?: boolean alignEnd?: boolean
} }
function dedupeStoredAccounts(accounts: TAccountPointer[]): TAccountPointer[] {
const seen = new Set<string>()
const out: TAccountPointer[] = []
for (const a of accounts) {
const raw = a.pubkey?.trim()
if (!raw) continue
const p = normalizeHexPubkey(raw)
if (seen.has(p)) continue
seen.add(p)
out.push(a)
}
return out
}
/** /**
* Switch {@link useNostr} session among stored accounts (notifications spell, post editor). * Switch {@link useNostr} session among stored accounts (notifications spell, post editor).
* Avatar chips instead of a native select; NIP-07 extension sync hint + retry when read-only. * Avatar chips instead of a native select; NIP-07 extension sync hint + retry when read-only.
@ -54,12 +41,13 @@ export default function StoredAccountSwitchSelect({
account, account,
accounts, accounts,
switchAccount, switchAccount,
viewAccountAsReadOnly,
isAccountSessionHydrating, isAccountSessionHydrating,
retryNip07SignerForPreferredAccount, retryNip07SignerForPreferredAccount,
adoptExtensionNip07Identity adoptExtensionNip07Identity
} = useNostr() } = useNostr()
const [switchingPubkey, setSwitchingPubkey] = useState<string | null>(null) const [switchingKey, setSwitchingKey] = useState<string | null>(null)
const [retryingExtension, setRetryingExtension] = useState(false) const [retryingExtension, setRetryingExtension] = useState(false)
const [extensionPubkey, setExtensionPubkey] = useState<string | null>(null) const [extensionPubkey, setExtensionPubkey] = useState<string | null>(null)
@ -68,19 +56,16 @@ export default function StoredAccountSwitchSelect({
return cur ? normalizeHexPubkey(cur) : null return cur ? normalizeHexPubkey(cur) : null
}, [pubkey]) }, [pubkey])
const storedAccounts = useMemo(() => dedupeStoredAccounts(accounts), [accounts]) const storedAccounts = useMemo(() => listSwitchableAccounts(accounts), [accounts])
const activeStoredAccount = useMemo(() => {
if (!sessionPubkey) return null
return (
storedAccounts.find((a) => hexPubkeysEqual(normalizeHexPubkey(a.pubkey), sessionPubkey)) ?? null
)
}, [storedAccounts, sessionPubkey])
const needsExtensionSync = useMemo(() => { const needsExtensionSync = useMemo(() => {
if (!activeStoredAccount || !account) return false if (!sessionPubkey || !account || account.signerType !== 'npub') return false
return activeStoredAccount.signerType === 'nip-07' && account.signerType === 'npub' return accounts.some(
}, [activeStoredAccount, account]) (a) =>
a.signerType === 'nip-07' &&
hexPubkeysEqual(normalizeHexPubkey(a.pubkey), sessionPubkey)
)
}, [accounts, sessionPubkey, account])
const extensionDiffersFromSession = useMemo(() => { const extensionDiffersFromSession = useMemo(() => {
if (!extensionPubkey || !sessionPubkey) return false if (!extensionPubkey || !sessionPubkey) return false
@ -93,48 +78,46 @@ export default function StoredAccountSwitchSelect({
return return
} }
let cancelled = false let cancelled = false
const poll = async () => { void (async () => {
try { try {
const nip07Signer = new Nip07Signer() const nip07Signer = new Nip07Signer()
await nip07Signer.init() await nip07Signer.init()
const pk = await nip07Signer.getPublicKey() const pk = await nip07Signer.getPublicKey()
if (cancelled || !pk?.trim()) return if (!cancelled && pk?.trim()) setExtensionPubkey(pk)
setExtensionPubkey(pk)
if (
sessionPubkey &&
hexPubkeysEqual(normalizeHexPubkey(pk), sessionPubkey) &&
!retryingExtension
) {
const ok = await retryNip07SignerForPreferredAccount()
if (!cancelled && ok) {
toast.success(t('accountSwitch.extensionConnected'))
}
}
} catch { } catch {
if (!cancelled) setExtensionPubkey(null) if (!cancelled) setExtensionPubkey(null)
} }
} })()
void poll()
const id = window.setInterval(() => void poll(), 2_000)
return () => { return () => {
cancelled = true cancelled = true
window.clearInterval(id)
} }
}, [ }, [needsExtensionSync])
needsExtensionSync,
sessionPubkey,
retryNip07SignerForPreferredAccount,
retryingExtension,
t
])
const handlePick = useCallback( const handlePick = useCallback(
async (nextAccount: TAccountPointer) => { async (nextAccount: TAccountPointer) => {
const target = normalizeHexPubkey(nextAccount.pubkey) const target = normalizeHexPubkey(nextAccount.pubkey)
if (sessionPubkey && hexPubkeysEqual(target, sessionPubkey)) return if (isRedundantAccountPick(nextAccount, account)) {
setSwitchingPubkey(target) if (account?.signerType === 'npub' && nextAccount.signerType === 'nip-07') {
setSwitchingKey(accountPointerKey(nextAccount))
try {
const ok = await retryNip07SignerForPreferredAccount()
if (ok) toast.success(t('accountSwitch.extensionConnected'))
else toast.error(t('accountSwitch.extensionRetryFailed'))
} finally {
setSwitchingKey(null)
}
}
return
}
setSwitchingKey(accountPointerKey(nextAccount))
try { try {
const switched = await switchAccount(nextAccount) const needsWriteSigner =
nextAccount.signerType === 'nsec' ||
nextAccount.signerType === 'ncryptsec' ||
nextAccount.signerType === 'bunker'
const switched = needsWriteSigner
? await switchAccount(nextAccount)
: await viewAccountAsReadOnly(nextAccount)
if (!switched) { if (!switched) {
toast.error(t('notificationsSwitchAccountFailed')) toast.error(t('notificationsSwitchAccountFailed'))
return return
@ -143,14 +126,17 @@ export default function StoredAccountSwitchSelect({
toast.error(t('notificationsSwitchAccountFailed')) toast.error(t('notificationsSwitchAccountFailed'))
return return
} }
if (nextAccount.signerType === 'nip-07') {
await retryNip07SignerForPreferredAccount()
}
} finally { } finally {
setSwitchingPubkey(null) setSwitchingKey(null)
} }
}, },
[sessionPubkey, switchAccount, retryNip07SignerForPreferredAccount, t] [
account,
switchAccount,
viewAccountAsReadOnly,
retryNip07SignerForPreferredAccount,
t
]
) )
const handleRetryExtension = useCallback(async () => { const handleRetryExtension = useCallback(async () => {
@ -169,7 +155,7 @@ export default function StoredAccountSwitchSelect({
if (storedAccounts.length <= 1 || !sessionPubkey) return null if (storedAccounts.length <= 1 || !sessionPubkey) return null
const busy = isAccountSessionHydrating || switchingPubkey !== null const busy = isAccountSessionHydrating || switchingKey !== null
return ( return (
<div <div
@ -203,10 +189,14 @@ export default function StoredAccountSwitchSelect({
<div className="flex min-w-0 flex-wrap items-center gap-1.5"> <div className="flex min-w-0 flex-wrap items-center gap-1.5">
{storedAccounts.map((act) => { {storedAccounts.map((act) => {
const pk = normalizeHexPubkey(act.pubkey) const pk = normalizeHexPubkey(act.pubkey)
const isActive = hexPubkeysEqual(pk, sessionPubkey) const isActive =
const isSwitching = switchingPubkey !== null && hexPubkeysEqual(pk, switchingPubkey) hexPubkeysEqual(pk, sessionPubkey) &&
const readOnlyChip = (account?.signerType === act.signerType ||
isActive && act.signerType === 'nip-07' && account?.signerType === 'npub' (account?.signerType === 'npub' &&
act.signerType === 'nip-07' &&
needsExtensionSync))
const isSwitching = switchingKey === accountPointerKey(act)
const readOnlyChip = isActive && needsExtensionSync
return ( return (
<button <button
key={`${pk}-${act.signerType}`} key={`${pk}-${act.signerType}`}

2
src/i18n/locales/de.ts

@ -997,7 +997,7 @@ export default {
'Kontowechsel fehlgeschlagen. Signierer, Passwort oder Erweiterung prüfen und erneut versuchen.', 'Kontowechsel fehlgeschlagen. Signierer, Passwort oder Erweiterung prüfen und erneut versuchen.',
'accountSwitch.selectAccount': 'Zu {{pubkey}} wechseln', 'accountSwitch.selectAccount': 'Zu {{pubkey}} wechseln',
'accountSwitch.extensionSyncHint': 'accountSwitch.extensionSyncHint':
'Dieses Konto nutzt eine Browser-Erweiterung. Wähle denselben Schlüssel in der Erweiterung (Alby, nos2x, …) und tippe auf „Erneut“ — die App verbindet sich automatisch, sobald die Schlüssel übereinstimmen.', 'Nur-Lesen-Ansicht. „Erneut“ verbindet, wenn die Erweiterung zu diesem Schlüssel passt — ein anderer Erweiterungsschlüssel ist in Ordnung, wenn du nur stöbern willst.',
'accountSwitch.extensionRetry': 'Erweiterung erneut', 'accountSwitch.extensionRetry': 'Erweiterung erneut',
'accountSwitch.extensionConnected': 'Erweiterung für dieses Konto verbunden.', 'accountSwitch.extensionConnected': 'Erweiterung für dieses Konto verbunden.',
'accountSwitch.extensionRetryFailed': 'accountSwitch.extensionRetryFailed':

2
src/i18n/locales/en.ts

@ -1014,7 +1014,7 @@ export default {
'Could not switch account. Check your signer, password, or extension and try again.', 'Could not switch account. Check your signer, password, or extension and try again.',
'accountSwitch.selectAccount': 'Switch to {{pubkey}}', 'accountSwitch.selectAccount': 'Switch to {{pubkey}}',
'accountSwitch.extensionSyncHint': 'accountSwitch.extensionSyncHint':
'This account uses a browser extension. Select the same key in your extension (Alby, nos2x, …), then tap Retry — the app will connect automatically when the keys match.', 'Browsing read-only. Retry connects when your extension matches this key — a different extension key is fine if you only want to look around.',
'accountSwitch.extensionRetry': 'Retry extension', 'accountSwitch.extensionRetry': 'Retry extension',
'accountSwitch.extensionConnected': 'Extension connected for this account.', 'accountSwitch.extensionConnected': 'Extension connected for this account.',
'accountSwitch.extensionRetryFailed': 'accountSwitch.extensionRetryFailed':

58
src/lib/account.test.ts

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { isRedundantAccountPick, isSameAccount, listSwitchableAccounts } from './account'
describe('listSwitchableAccounts', () => {
const A = 'a'.repeat(64)
const B = 'b'.repeat(64)
it('returns every distinct pubkey', () => {
const out = listSwitchableAccounts([
{ pubkey: A, signerType: 'nip-07' },
{ pubkey: B, signerType: 'nip-07' }
])
expect(out).toHaveLength(2)
expect(out.map((a) => a.pubkey).sort()).toEqual([A, B].sort())
})
it('prefers nip-07 over npub for the same pubkey', () => {
const out = listSwitchableAccounts([
{ pubkey: A, signerType: 'npub' },
{ pubkey: A, signerType: 'nip-07' }
])
expect(out).toEqual([{ pubkey: A, signerType: 'nip-07' }])
})
it('keeps first preferred signer when only npub rows exist', () => {
const out = listSwitchableAccounts([{ pubkey: A, signerType: 'npub' }])
expect(out).toEqual([{ pubkey: A, signerType: 'npub' }])
})
})
describe('isSameAccount', () => {
const A = 'a'.repeat(64)
const Aupper = A.toUpperCase()
it('matches pubkeys case-insensitively', () => {
expect(
isSameAccount({ pubkey: A, signerType: 'nip-07' }, { pubkey: Aupper, signerType: 'nip-07' })
).toBe(true)
})
})
describe('isRedundantAccountPick', () => {
const A = 'a'.repeat(64)
it('blocks exact session row', () => {
const row = { pubkey: A, signerType: 'nip-07' as const }
expect(isRedundantAccountPick(row, row)).toBe(true)
})
it('allows nip-07 pick while session is read-only npub for same pubkey', () => {
expect(
isRedundantAccountPick(
{ pubkey: A, signerType: 'nip-07' },
{ pubkey: A, signerType: 'npub' }
)
).toBe(false)
})
})

72
src/lib/account.ts

@ -1,5 +1,73 @@
import { TAccountPointer } from '@/types' import { hexPubkeysEqual, isValidPubkey, normalizeHexPubkey } from '@/lib/pubkey'
import { TAccountPointer, TSignerType } from '@/types'
export function isSameAccount(a: TAccountPointer | null, b: TAccountPointer | null) { export function isSameAccount(a: TAccountPointer | null, b: TAccountPointer | null) {
return a?.pubkey === b?.pubkey && a?.signerType === b?.signerType if (!a || !b) return false
if (a.signerType !== b.signerType) return false
return hexPubkeysEqual(normalizeHexPubkey(a.pubkey), normalizeHexPubkey(b.pubkey))
}
/** Same hex pubkey, regardless of signer type (e.g. npub vs nip-07 rows). */
export function isSameAccountPubkey(a: TAccountPointer | null, b: TAccountPointer | null) {
if (!a || !b) return false
return hexPubkeysEqual(normalizeHexPubkey(a.pubkey), normalizeHexPubkey(b.pubkey))
}
/** False when the user should be allowed to pick this row (e.g. reconnect nip-07 while read-only). */
export function isRedundantAccountPick(target: TAccountPointer, session: TAccountPointer | null) {
if (!session) return false
if (isSameAccount(target, session)) return true
if (
session.signerType === 'npub' &&
target.signerType === 'npub' &&
isSameAccountPubkey(target, session)
) {
return true
}
return false
}
const SWITCH_SIGNER_PRIORITY: Record<TSignerType, number> = {
'nip-07': 0,
nsec: 1,
'browser-nsec': 1,
ncryptsec: 2,
bunker: 3,
npub: 4
}
function normalizedPubkeyHex(account: TAccountPointer): string | null {
const raw = account.pubkey?.trim()
if (!raw) return null
const pk = normalizeHexPubkey(raw)
return isValidPubkey(pk) ? pk : null
}
/**
* One entry per pubkey for account switcher chips keeps every identity that has logged in,
* and prefers a signable signer when the same pubkey has both npub and nip-07 rows.
*/
export function listSwitchableAccounts(accounts: readonly TAccountPointer[]): TAccountPointer[] {
const byPubkey = new Map<string, TAccountPointer>()
for (const a of accounts) {
const pk = normalizedPubkeyHex(a)
if (!pk) continue
const row: TAccountPointer = { pubkey: pk, signerType: a.signerType }
const existing = byPubkey.get(pk)
if (!existing) {
byPubkey.set(pk, row)
continue
}
const curPri = SWITCH_SIGNER_PRIORITY[a.signerType] ?? 99
const exPri = SWITCH_SIGNER_PRIORITY[existing.signerType] ?? 99
if (curPri < exPri) {
byPubkey.set(pk, row)
}
}
return Array.from(byPubkey.values())
}
export function accountPointerKey(account: TAccountPointer): string {
const pk = normalizedPubkeyHex(account)
return pk ? `${pk}:${account.signerType}` : account.signerType
} }

23
src/lib/pubkey-nip07.test.ts

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { getPublicKey, generateSecretKey, nip19 } from 'nostr-tools'
import { pubkeyFromNip07Extension } from './pubkey'
describe('pubkeyFromNip07Extension', () => {
it('accepts hex pubkey', () => {
const sk = generateSecretKey()
const hex = getPublicKey(sk)
expect(pubkeyFromNip07Extension(hex)).toBe(hex.toLowerCase())
})
it('accepts npub', () => {
const sk = generateSecretKey()
const hex = getPublicKey(sk)
const npub = nip19.npubEncode(hex)
expect(pubkeyFromNip07Extension(npub)).toBe(hex.toLowerCase())
})
it('rejects invalid input', () => {
expect(pubkeyFromNip07Extension('not-a-key')).toBeNull()
expect(pubkeyFromNip07Extension('')).toBeNull()
})
})

11
src/lib/pubkey.ts

@ -97,6 +97,17 @@ export function isValidPubkey(pubkey: string) {
return /^[0-9a-f]{64}$/i.test(pubkey) return /^[0-9a-f]{64}$/i.test(pubkey)
} }
/** Hex pubkey from a NIP-07 `getPublicKey()` result (hex or npub / nostr: URI). */
export function pubkeyFromNip07Extension(raw: string | null | undefined): string | null {
if (raw == null) return null
const trimmed = raw.trim()
if (!trimmed) return null
const fromId = userIdToPubkey(trimmed)
if (isValidPubkey(fromId)) return fromId.toLowerCase()
if (isValidPubkey(trimmed)) return trimmed.toLowerCase()
return null
}
/** Hex pubkey from pasted npub / nprofile / hex / `nostr:` URL (e.g. invite lists). */ /** Hex pubkey from pasted npub / nprofile / hex / `nostr:` URL (e.g. invite lists). */
export function inviteInputToHexPubkey(raw: string): string | null { export function inviteInputToHexPubkey(raw: string): string | null {
const t = raw.trim().replace(/^nostr:/i, '').trim() const t = raw.trim().replace(/^nostr:/i, '').trim()

247
src/providers/NostrProvider/index.tsx

@ -47,7 +47,7 @@ import {
} from '@/lib/viewer-blocked-relays' } from '@/lib/viewer-blocked-relays'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, hexPubkeysEqual, isValidPubkey, normalizeHexPubkey, pubkeyToNpub, pubkeyFromNip07Extension } from '@/lib/pubkey'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import client from '@/services/client.service' import client from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service' import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
@ -75,6 +75,8 @@ import { NostrContext, type TNostrContext } from '@/providers/nostr-context'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEventCallback } from '@/hooks/use-event-callback' import { useEventCallback } from '@/hooks/use-event-callback'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { isSameAccount } from '@/lib/account'
import { flushSync } from 'react-dom'
import { showNip07ExtensionKeyMismatchToast } from '@/lib/nip07-extension-key-mismatch-toast' import { showNip07ExtensionKeyMismatchToast } from '@/lib/nip07-extension-key-mismatch-toast'
import { toast } from 'sonner' import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer' import { BunkerSigner } from './bunker.signer'
@ -139,6 +141,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [ncryptsec, setNcryptsec] = useState<string | null>(null) const [ncryptsec, setNcryptsec] = useState<string | 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 [isNip07LoginInFlight, setIsNip07LoginInFlight] = useState(false)
const nip07LoginInFlightRef = useRef(false)
const [ncryptsecPasswordOpen, setNcryptsecPasswordOpen] = useState(false) const [ncryptsecPasswordOpen, setNcryptsecPasswordOpen] = useState(false)
const ncryptsecPasswordResolveRef = useRef<((value: string | null) => void) | null>(null) const ncryptsecPasswordResolveRef = useRef<((value: string | null) => void) | null>(null)
/** One toast per mismatch episode; cleared after a successful NIP-07 login. */ /** One toast per mismatch episode; cleared after a successful NIP-07 login. */
@ -1131,22 +1135,78 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
} }
const syncAccountPointersFromStorage = () => {
setAccounts(
storage.getAccounts().map((act) => ({ pubkey: act.pubkey, signerType: act.signerType }))
)
}
const normalizeLoginAccount = (act: TAccount): TAccount => {
const pubkey =
pubkeyFromNip07Extension(act.pubkey) ??
(isValidPubkey(normalizeHexPubkey(act.pubkey))
? normalizeHexPubkey(act.pubkey)
: act.pubkey)
return { ...act, pubkey }
}
const clearSessionUiForAccountChange = () => {
setProfile(null)
setProfileEvent(null)
setRelayList(null)
setNsec(null)
setNcryptsec(null)
setFavoriteRelaysEvent(null)
setFollowListEvent(null)
setMuteListEvent(null)
setBookmarkListEvent(null)
setInterestListEvent(null)
setRssFeedListEvent(null)
setCacheRelayListEvent(null)
setHttpRelayListEvent(undefined)
setBlockedRelaysEvent(null)
setUserEmojiListEvent(null)
}
const login = (signer: ISigner, act: TAccount) => { const login = (signer: ISigner, act: TAccount) => {
if (act.signerType === 'nip-07') { const normalized = normalizeLoginAccount(act)
const prev = accountForReplaceablesSyncRef.current
if (normalized.signerType === 'nip-07') {
nip07KeyMismatchToastShownRef.current = false nip07KeyMismatchToastShownRef.current = false
intentionalNip07ReadOnlyPubkeyRef.current = null intentionalNip07ReadOnlyPubkeyRef.current = null
} }
const newAccounts = storage.addAccount(act) storage.addAccount(normalized)
setAccounts(newAccounts) syncAccountPointersFromStorage()
storage.switchAccount(act) storage.switchAccount(normalized)
setAccount({ pubkey: act.pubkey, signerType: act.signerType })
const sessionChanged =
!prev ||
!hexPubkeysEqual(normalizeHexPubkey(prev.pubkey), normalized.pubkey) ||
prev.signerType !== normalized.signerType
if (sessionChanged) {
clearSessionUiForAccountChange()
accountHydrationGenerationRef.current += 1
lastNetworkHydrateAccountPubkeyRef.current = null
}
const pointer = { pubkey: normalized.pubkey, signerType: normalized.signerType }
setAccount(pointer)
setSigner(signer) setSigner(signer)
return act.pubkey accountForReplaceablesSyncRef.current = pointer
client.setSigner(signer, normalized.signerType)
client.pubkey = normalized.pubkey
void client.syncViewerPersonalRelayKeys(normalized.pubkey)
if (sessionChanged) {
setAccountNetworkHydrateBump((n) => n + 1)
}
return normalized.pubkey
} }
const removeAccount = (act: TAccountPointer) => { const removeAccount = (act: TAccountPointer) => {
const newAccounts = storage.removeAccount(act) storage.removeAccount(act)
setAccounts(newAccounts) syncAccountPointersFromStorage()
if (account?.pubkey === act.pubkey) { if (account?.pubkey === act.pubkey) {
setAccount(null) setAccount(null)
setSigner(null) setSigner(null)
@ -1190,19 +1250,72 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const result = await loginWithAccountPointer(act, { userInitiatedSwitch: true }) const result = await loginWithAccountPointer(act, { userInitiatedSwitch: true })
// If loginWithAccountPointer fell back to read-only npub it skips storage.switchAccount. // If loginWithAccountPointer fell back to read-only npub it skips storage.switchAccount.
// Persist the user's intent here so: // Persist the user's intent here so session restore and NIP-07 recovery target this row.
// • session restore on refresh targets the right account, and if (result !== null) {
// • the NIP-07 recovery loop (which reads storage.getCurrentAccount) can fire.
if (result !== null && storage.getCurrentAccount()?.pubkey !== act.pubkey) {
const storedFull = storage.findAccount(act) const storedFull = storage.findAccount(act)
if (storedFull) { if (storedFull && !isSameAccount(storage.getCurrentAccount(), storedFull)) {
storage.switchAccount(storedFull) storage.switchAccount(storedFull)
syncAccountPointersFromStorage()
setNip07RecoveryBump((b) => b + 1) setNip07RecoveryBump((b) => b + 1)
} }
} }
return result return result
} }
/** Browse as an identity without requiring the browser extension to match (no NIP-07 recovery loop). */
const viewAccountAsReadOnly = async (act: TAccountPointer): Promise<string | null> => {
const stored = storage.findAccount(act)
const normalized = normalizeLoginAccount(
stored ?? { pubkey: act.pubkey, signerType: act.signerType }
)
if (!isValidPubkey(normalized.pubkey)) return null
intentionalNip07ReadOnlyPubkeyRef.current = normalized.pubkey.toLowerCase()
nip07KeyMismatchToastShownRef.current = true
const storageRow: TAccount =
stored ??
({
pubkey: normalized.pubkey,
signerType: 'npub',
npub: nip19.npubEncode(normalized.pubkey)
} as TAccount)
storage.switchAccount(
stored?.signerType === 'nip-07' ? stored : storageRow
)
syncAccountPointersFromStorage()
const npubSigner = new NpubSigner()
npubSigner.login(nip19.npubEncode(normalized.pubkey))
return flushSync(() => {
const prev = accountForReplaceablesSyncRef.current
const sessionChanged =
!prev ||
!hexPubkeysEqual(normalizeHexPubkey(prev.pubkey), normalized.pubkey) ||
prev.signerType !== 'npub'
if (sessionChanged) {
clearSessionUiForAccountChange()
accountHydrationGenerationRef.current += 1
lastNetworkHydrateAccountPubkeyRef.current = null
}
const pointer = { pubkey: normalized.pubkey, signerType: 'npub' as const }
setAccount(pointer)
setSigner(npubSigner)
accountForReplaceablesSyncRef.current = pointer
client.setSigner(npubSigner, 'npub')
client.pubkey = normalized.pubkey
void client.syncViewerPersonalRelayKeys(normalized.pubkey)
if (sessionChanged) {
setAccountNetworkHydrateBump((n) => n + 1)
}
return normalized.pubkey
})
}
const finishNcryptsecPasswordPrompt = useCallback((password: string | null) => { const finishNcryptsecPasswordPrompt = useCallback((password: string | null) => {
const resolve = ncryptsecPasswordResolveRef.current const resolve = ncryptsecPasswordResolveRef.current
if (!resolve) return if (!resolve) return
@ -1282,17 +1395,35 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const nip07Login = async () => { const nip07Login = async () => {
if (nip07LoginInFlightRef.current) return null
nip07LoginInFlightRef.current = true
setIsNip07LoginInFlight(true)
try { try {
const nip07Signer = new Nip07Signer() const nip07Signer = new Nip07Signer()
await nip07Signer.init() await nip07Signer.init()
const pubkey = await nip07Signer.getPublicKey() const raw = await nip07Signer.getPublicKey()
const pubkey = pubkeyFromNip07Extension(raw)
if (!pubkey) { if (!pubkey) {
throw new Error('You did not allow to access your pubkey') throw new Error(
raw
? 'Extension returned an invalid pubkey'
: 'You did not allow to access your pubkey'
)
}
const readOnlyDup = storage
.getAccounts()
.find((a) => a.pubkey === pubkey && a.signerType === 'npub')
if (readOnlyDup) {
storage.removeAccount(readOnlyDup)
syncAccountPointersFromStorage()
} }
return login(nip07Signer, { pubkey, signerType: 'nip-07' }) return flushSync(() => login(nip07Signer, { pubkey, signerType: 'nip-07' }))
} catch (err) { } catch (err) {
toast.error(t('Login failed') + ': ' + (err as Error).message) toast.error(t('Login failed') + ': ' + (err as Error).message)
throw err throw err
} finally {
nip07LoginInFlightRef.current = false
setIsNip07LoginInFlight(false)
} }
} }
@ -1384,10 +1515,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
try { try {
const nip07Signer = new Nip07Signer() const nip07Signer = new Nip07Signer()
await nip07Signer.init() await nip07Signer.init()
const pubkey = await nip07Signer.getPublicKey() const pubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey())
if (pubkey.toLowerCase() !== storedAccount.pubkey.toLowerCase()) { if (!pubkey || pubkey !== storedAccount.pubkey.toLowerCase()) {
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG) throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
} }
if (pubkey !== storedAccount.pubkey) {
storedAccount = { ...storedAccount, pubkey }
}
return login(nip07Signer, storedAccount) return login(nip07Signer, storedAccount)
} catch (err) { } catch (err) {
let lastNip07Err: unknown = err let lastNip07Err: unknown = err
@ -1396,11 +1530,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
await new Promise((resolve) => setTimeout(resolve, 1200)) await new Promise((resolve) => setTimeout(resolve, 1200))
const retrySigner = new Nip07Signer() const retrySigner = new Nip07Signer()
await retrySigner.init() await retrySigner.init()
const retryPubkey = await retrySigner.getPublicKey() const retryPubkey = pubkeyFromNip07Extension(await retrySigner.getPublicKey())
if (retryPubkey.toLowerCase() !== storedAccount.pubkey.toLowerCase()) { if (!retryPubkey || retryPubkey !== storedAccount.pubkey.toLowerCase()) {
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG) throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
} }
return login(retrySigner, storedAccount) const act =
retryPubkey !== storedAccount.pubkey
? { ...storedAccount, pubkey: retryPubkey }
: storedAccount
return login(retrySigner, act)
} catch (retryErr) { } catch (retryErr) {
lastNip07Err = retryErr lastNip07Err = retryErr
// If this tab already has a working nip-07 signer for the same account, keep it. // If this tab already has a working nip-07 signer for the same account, keep it.
@ -1477,14 +1615,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
try { try {
const nip07Signer = new Nip07Signer() const nip07Signer = new Nip07Signer()
await nip07Signer.init() await nip07Signer.init()
const extPubkey = await nip07Signer.getPublicKey() const extPubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey())
if (!extPubkey?.trim()) return false if (!extPubkey || extPubkey !== preferred.pubkey.toLowerCase()) {
if (extPubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) {
return false return false
} }
intentionalNip07ReadOnlyPubkeyRef.current = null intentionalNip07ReadOnlyPubkeyRef.current = null
nip07KeyMismatchToastShownRef.current = false nip07KeyMismatchToastShownRef.current = false
login(nip07Signer, preferred) const act =
extPubkey !== preferred.pubkey ? { ...preferred, pubkey: extPubkey } : preferred
login(nip07Signer, act)
setNip07RecoveryBump((b) => b + 1) setNip07RecoveryBump((b) => b + 1)
return true return true
} catch (e) { } catch (e) {
@ -1501,22 +1640,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
intentionalNip07ReadOnlyPubkeyRef.current = null intentionalNip07ReadOnlyPubkeyRef.current = null
const nip07Signer = new Nip07Signer() const nip07Signer = new Nip07Signer()
await nip07Signer.init() await nip07Signer.init()
const extPubkey = await nip07Signer.getPublicKey() const extPubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey())
if (!extPubkey?.trim()) { if (!extPubkey) {
throw new Error('Empty pubkey from extension') throw new Error('Empty or invalid pubkey from extension')
} }
const preferred = storage.getCurrentAccount() const readOnlyDup = storage
if ( .getAccounts()
preferred?.signerType === 'nip-07' && .find((a) => a.pubkey === extPubkey && a.signerType === 'npub')
preferred.pubkey.toLowerCase() !== extPubkey.toLowerCase() if (readOnlyDup) {
) { storage.removeAccount(readOnlyDup)
removeAccount(preferred) syncAccountPointersFromStorage()
} }
const existing = storage const existing = storage
.getAccounts() .getAccounts()
.find((a) => a.pubkey.toLowerCase() === extPubkey.toLowerCase() && a.signerType === 'nip-07') .find((a) => a.pubkey === extPubkey && a.signerType === 'nip-07')
const act: TAccount = existing ?? { pubkey: extPubkey, signerType: 'nip-07' } const act: TAccount = existing ?? { pubkey: extPubkey, signerType: 'nip-07' }
login(nip07Signer, act) flushSync(() => login(nip07Signer, act))
setAccountNetworkHydrateBump((n) => n + 1)
toast.success(t('nip07.switchedToExtensionIdentity')) toast.success(t('nip07.switchedToExtensionIdentity'))
} catch (e) { } catch (e) {
toast.error(`${t('nip07.adoptExtensionFailed')}: ${e instanceof Error ? e.message : String(e)}`) toast.error(`${t('nip07.adoptExtensionFailed')}: ${e instanceof Error ? e.message : String(e)}`)
@ -1540,9 +1680,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
*/ */
useEffect(() => { useEffect(() => {
if (!account || account.signerType !== 'npub') return if (!account || account.signerType !== 'npub') return
const intentionalPk = intentionalNip07ReadOnlyPubkeyRef.current
if (
intentionalPk &&
hexPubkeysEqual(normalizeHexPubkey(account.pubkey), intentionalPk)
) {
return
}
const preferred = storage.getCurrentAccount() const preferred = storage.getCurrentAccount()
if (!preferred || preferred.signerType !== 'nip-07') return if (!preferred || preferred.signerType !== 'nip-07') return
if (preferred.pubkey !== account.pubkey) return if (!hexPubkeysEqual(normalizeHexPubkey(preferred.pubkey), normalizeHexPubkey(account.pubkey))) {
return
}
let cancelled = false let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
@ -1579,13 +1728,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
attempts, attempts,
wantedPubkey: preferred.pubkey.slice(0, 12) wantedPubkey: preferred.pubkey.slice(0, 12)
}) })
const quietReadOnly = if (intentionalNip07ReadOnlyPubkeyRef.current) {
intentionalNip07ReadOnlyPubkeyRef.current === preferred.pubkey.toLowerCase() return
if (!quietReadOnly) {
fireNip07ExtensionKeyMismatchToast()
} }
// Keep retrying — the extension may update its approved key after a moment. fireNip07ExtensionKeyMismatchToast()
schedule(quietReadOnly ? 2_000 : 3_000) schedule(3_000)
return return
} }
logger.info('[NostrProvider] NIP-07 recovery retry failed', { logger.info('[NostrProvider] NIP-07 recovery retry failed', {
@ -2089,6 +2236,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const removeAccountStable = useEventCallback(removeAccount) const removeAccountStable = useEventCallback(removeAccount)
const discardLocalPrivateKeyStable = useEventCallback(discardLocalPrivateKey) const discardLocalPrivateKeyStable = useEventCallback(discardLocalPrivateKey)
const switchAccountStable = useEventCallback(switchAccount) const switchAccountStable = useEventCallback(switchAccount)
const viewAccountAsReadOnlyStable = useEventCallback(viewAccountAsReadOnly)
const retryNip07SignerForPreferredAccountStable = useEventCallback(retryNip07SignerForPreferredAccount) const retryNip07SignerForPreferredAccountStable = useEventCallback(retryNip07SignerForPreferredAccount)
const adoptExtensionNip07IdentityStable = useEventCallback(adoptCurrentExtensionNip07Identity) const adoptExtensionNip07IdentityStable = useEventCallback(adoptCurrentExtensionNip07Identity)
const nsecLoginStable = useEventCallback(nsecLogin) const nsecLoginStable = useEventCallback(nsecLogin)
@ -2121,6 +2269,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
(): TNostrContext => ({ (): TNostrContext => ({
isInitialized, isInitialized,
isAccountSessionHydrating, isAccountSessionHydrating,
isNip07LoginInFlight,
pubkey: account?.pubkey ?? null, pubkey: account?.pubkey ?? null,
profile, profile,
profileEvent, profileEvent,
@ -2137,9 +2286,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
rssFeedListEvent, rssFeedListEvent,
account, account,
accounts, accounts,
canSignEvents: account != null && account.signerType !== 'npub',
nsec, nsec,
ncryptsec, ncryptsec,
switchAccount: switchAccountStable, switchAccount: switchAccountStable,
viewAccountAsReadOnly: viewAccountAsReadOnlyStable,
retryNip07SignerForPreferredAccount: retryNip07SignerForPreferredAccountStable, retryNip07SignerForPreferredAccount: retryNip07SignerForPreferredAccountStable,
adoptExtensionNip07Identity: adoptExtensionNip07IdentityStable, adoptExtensionNip07Identity: adoptExtensionNip07IdentityStable,
nsecLogin: nsecLoginStable, nsecLogin: nsecLoginStable,
@ -2175,6 +2326,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
[ [
isInitialized, isInitialized,
isAccountSessionHydrating, isAccountSessionHydrating,
isNip07LoginInFlight,
account, account,
accounts, accounts,
attemptDeleteStable, attemptDeleteStable,
@ -2209,6 +2361,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
signHttpAuthStable, signHttpAuthStable,
startLogin, startLogin,
switchAccountStable, switchAccountStable,
viewAccountAsReadOnlyStable,
retryNip07SignerForPreferredAccountStable, retryNip07SignerForPreferredAccountStable,
adoptExtensionNip07IdentityStable, adoptExtensionNip07IdentityStable,
updateBlockedRelaysEventStable, updateBlockedRelaysEventStable,
@ -2230,7 +2383,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return ( return (
<NostrContext.Provider value={nostrContextValue}> <NostrContext.Provider value={nostrContextValue}>
{children} {children}
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} /> <LoginDialog
open={openLoginDialog}
setOpen={setOpenLoginDialog}
blockClose={isNip07LoginInFlight}
/>
<NcryptsecPasswordPrompt open={ncryptsecPasswordOpen} onResult={finishNcryptsecPasswordPrompt} /> <NcryptsecPasswordPrompt open={ncryptsecPasswordOpen} onResult={finishNcryptsecPasswordPrompt} />
</NostrContext.Provider> </NostrContext.Provider>
) )

8
src/providers/nostr-context.tsx

@ -35,15 +35,21 @@ export type TNostrContext = {
accounts: TAccountPointer[] accounts: TAccountPointer[]
nsec: string | null nsec: string | null
ncryptsec: string | null ncryptsec: string | null
/** True when the session can sign (not read-only npub fallback). */
canSignEvents: boolean
/** Returns the new session pubkey on success, or `null` if logout / switch failed. */ /** Returns the new session pubkey on success, or `null` if logout / switch failed. */
switchAccount: (account: TAccountPointer | null) => Promise<string | null> switchAccount: (account: TAccountPointer | null) => Promise<string | null>
/** View an account read-only (notifications, relays) without matching the browser extension. */
viewAccountAsReadOnly: (account: TAccountPointer) => Promise<string | null>
/** Reconnect NIP-07 when the extension pubkey matches the stored preferred account. */ /** Reconnect NIP-07 when the extension pubkey matches the stored preferred account. */
retryNip07SignerForPreferredAccount: () => Promise<boolean> retryNip07SignerForPreferredAccount: () => Promise<boolean>
/** Sign in with whichever pubkey the browser extension exposes now. */ /** Sign in with whichever pubkey the browser extension exposes now. */
adoptExtensionNip07Identity: () => Promise<void> adoptExtensionNip07Identity: () => Promise<void>
/** True while the login modal must stay open for an in-flight NIP-07 authorize. */
isNip07LoginInFlight: boolean
nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise<string> nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise<string>
ncryptsecLogin: (ncryptsec: string) => Promise<string> ncryptsecLogin: (ncryptsec: string) => Promise<string>
nip07Login: () => Promise<string> nip07Login: () => Promise<string | null>
bunkerLogin: (bunker: string) => Promise<string> bunkerLogin: (bunker: string) => Promise<string>
nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string> nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string>
npubLogin(npub: string): Promise<string> npubLogin(npub: string): Promise<string>

Loading…
Cancel
Save