@ -1,8 +1,12 @@
import UserAvatar from '@/components/UserAvatar'
import { Simple UserAvatar } from '@/components/UserAvatar'
import { cn } from '@/lib/utils '
import { Button } from '@/components/ui/button '
import { formatPubkey , hexPubkeysEqual , normalizeHexPubkey } from '@/lib/pubkey'
import { formatPubkey , hexPubkeysEqual , normalizeHexPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { Nip07Signer } from '@/providers/NostrProvider/nip-07.signer'
import { useNostr } from '@/providers/NostrProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useCallback , useMemo } from 'react'
import type { TAccountPointer } from '@/types'
import { Loader2 } from 'lucide-react'
import { useCallback , useEffect , useMemo , useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { toast } from 'sonner'
@ -11,99 +15,273 @@ type Props = {
triggerClassName? : string
triggerClassName? : string
/** Show the inline label on narrow viewports (e.g. full-screen post composer). */
/** Show the inline label on narrow viewports (e.g. full-screen post composer). */
showLabelAlways? : boolean
showLabelAlways? : boolean
/** Separator under the row (e.g. post editor header) . */
/** Separator under the row. */
withBottomBorder? : boolean
withBottomBorder? : boolean
/** Separator above the row (e.g. post editor footer). */
withTopBorder? : boolean
/** Align chips to the end (e.g. beside the publish button). */
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 ( same as notifications spell ) .
* Switch { @link useNostr } session among stored accounts ( notifications spell , post editor ) .
* Renders nothing when there is only one stored account or no session .
* Avatar chips instead of a native select ; NIP - 07 extension sync hint + retry when read - only .
*
* Uses a native { @link HTMLSelectElement } instead of Radix Select : nested ` UserAvatar ` /
* ` Username ` inside ` SelectItem ` composes refs in ways that have triggered
* “ Maximum update depth exceeded ” on this page ( Radix ` compose-refs ` + frequent re - renders ) .
* /
* /
export default function StoredAccountSwitchSelect ( {
export default function StoredAccountSwitchSelect ( {
className ,
className ,
triggerClassName ,
showLabelAlways = false ,
showLabelAlways = false ,
withBottomBorder = false
withBottomBorder = false ,
withTopBorder = false ,
alignEnd = false
} : Props ) {
} : Props ) {
const { t } = useTranslation ( )
const { t } = useTranslation ( )
const { pubkey , accounts , switchAccount , isAccountSessionHydrating } = useNostr ( )
const {
pubkey ,
account ,
accounts ,
switchAccount ,
isAccountSessionHydrating ,
retryNip07SignerForPreferredAccount ,
adoptExtensionNip07Identity
} = useNostr ( )
const [ switchingPubkey , setSwitchingPubkey ] = useState < string | null > ( null )
const [ retryingExtension , setRetryingExtension ] = useState ( false )
const [ extensionPubkey , setExtensionPubkey ] = useState < string | null > ( null )
const sessionPubkey = useMemo ( ( ) = > {
const sessionPubkey = useMemo ( ( ) = > {
const cur = pubkey ? . trim ( )
const cur = pubkey ? . trim ( )
return cur ? normalizeHexPubkey ( cur ) : null
return cur ? normalizeHexPubkey ( cur ) : null
} , [ pubkey ] )
} , [ pubkey ] )
const storedAccountPubkeys = useMemo ( ( ) = > {
const storedAccounts = useMemo ( ( ) = > dedupeStoredAccounts ( accounts ) , [ accounts ] )
const seen = new Set < string > ( )
const out : string [ ] = [ ]
const activeStoredAccount = useMemo ( ( ) = > {
for ( const a of accounts ) {
if ( ! sessionPubkey ) return null
const raw = a . pubkey ? . trim ( )
return (
if ( ! raw ) continue
storedAccounts . find ( ( a ) = > hexPubkeysEqual ( normalizeHexPubkey ( a . pubkey ) , sessionPubkey ) ) ? ? null
const p = normalizeHexPubkey ( raw )
)
if ( ! seen . has ( p ) ) {
} , [ storedAccounts , sessionPubkey ] )
seen . add ( p )
out . push ( p )
const needsExtensionSync = useMemo ( ( ) = > {
if ( ! activeStoredAccount || ! account ) return false
return activeStoredAccount . signerType === 'nip-07' && account . signerType === 'npub'
} , [ activeStoredAccount , account ] )
const extensionDiffersFromSession = useMemo ( ( ) = > {
if ( ! extensionPubkey || ! sessionPubkey ) return false
return ! hexPubkeysEqual ( normalizeHexPubkey ( extensionPubkey ) , sessionPubkey )
} , [ extensionPubkey , sessionPubkey ] )
useEffect ( ( ) = > {
if ( ! needsExtensionSync ) {
setExtensionPubkey ( null )
return
}
let cancelled = false
const poll = async ( ) = > {
try {
const nip07Signer = new Nip07Signer ( )
await nip07Signer . init ( )
const pk = await nip07Signer . getPublicKey ( )
if ( cancelled || ! pk ? . trim ( ) ) return
setExtensionPubkey ( pk )
if (
sessionPubkey &&
hexPubkeysEqual ( normalizeHexPubkey ( pk ) , sessionPubkey ) &&
! retryingExtension
) {
const ok = await retryNip07SignerForPreferredAccount ( )
if ( ! cancelled && ok ) {
toast . success ( t ( 'accountSwitch.extensionConnected' ) )
}
}
} catch {
if ( ! cancelled ) setExtensionPubkey ( null )
}
}
}
}
return out
void poll ( )
} , [ accounts ] )
const id = window . setInterval ( ( ) = > void poll ( ) , 2 _000 )
return ( ) = > {
cancelled = true
window . clearInterval ( id )
}
} , [
needsExtensionSync ,
sessionPubkey ,
retryNip07SignerForPreferredAccount ,
retryingExtension ,
t
] )
const handlePick = useCallback (
const handlePick = useCallback (
async ( v : string ) = > {
async ( nextAccount : TAccountPointer ) = > {
const target = normalizeHexPubkey ( v )
const target = normalizeHexPubkey ( nextAccount . pubkey )
if ( pubkey && hexPubkeysEqual ( target , normalizeHexPubkey ( pubkey ) ) ) return
if ( sessionPubkey && hexPubkeysEqual ( target , sessionPubkey ) ) return
const nextAccount = accounts . find ( ( a ) = > hexPubkeysEqual ( normalizeHexPubkey ( a . pubkey ) , target ) )
setSwitchingPubkey ( target )
if ( ! nextAccount ) {
try {
toast . error ( t ( 'notificationsSwitchAccountFailed' ) )
const switched = await switchAccount ( nextAccount )
return
if ( ! switched ) {
}
toast . error ( t ( 'notificationsSwitchAccountFailed' ) )
const switched = await switchAccount ( nextAccount )
return
if ( ! switched || ! hexPubkeysEqual ( normalizeHexPubkey ( switched ) , target ) ) {
}
toast . error ( t ( 'notificationsSwitchAccountFailed' ) )
if ( ! hexPubkeysEqual ( normalizeHexPubkey ( switched ) , target ) ) {
toast . error ( t ( 'notificationsSwitchAccountFailed' ) )
return
}
if ( nextAccount . signerType === 'nip-07' ) {
await retryNip07SignerForPreferredAccount ( )
}
} finally {
setSwitchingPubkey ( null )
}
}
} ,
} ,
[ pubkey , accounts , switchAccount , t ]
[ sessionPubkey , switchAccount , retryNip07SignerForPreferred Account, t ]
)
)
if ( storedAccountPubkeys . length <= 1 || ! sessionPubkey ) return null
const handleRetryExtension = useCallback ( async ( ) = > {
setRetryingExtension ( true )
try {
const ok = await retryNip07SignerForPreferredAccount ( )
if ( ok ) {
toast . success ( t ( 'accountSwitch.extensionConnected' ) )
} else {
toast . error ( t ( 'accountSwitch.extensionRetryFailed' ) )
}
} finally {
setRetryingExtension ( false )
}
} , [ retryNip07SignerForPreferredAccount , t ] )
if ( storedAccounts . length <= 1 || ! sessionPubkey ) return null
const busy = isAccountSessionHydrating || switchingPubkey !== null
return (
return (
< div
< div
className = { cn (
className = { cn (
'flex min-w-0 items-center gap-2' ,
'flex min-w-0 flex-col gap-2' ,
alignEnd && 'items-end' ,
withBottomBorder && '-mx-1 mb-1 border-b border-border/60 px-1 pb-3' ,
withBottomBorder && '-mx-1 mb-1 border-b border-border/60 px-1 pb-3' ,
withTopBorder &&
( alignEnd
? '-mx-1 mt-1 border-t border-border/60 px-1 pt-2'
: '-mx-1 mt-2 border-t border-border/60 px-1 pt-3' ) ,
className
className
) }
) }
role = "group"
aria - label = { t ( 'notificationsViewAsAccountAria' ) }
>
>
< span
< div
className = { cn (
className = { cn (
'shrink-0 text-xs text-muted-foreground' ,
'flex min-w-0 flex-wrap items-center gap-2 ' ,
showLabelAlways ? 'inline' : 'hidden sm:inline'
alignEnd && 'justify-end '
) }
) }
>
>
{ t ( 'notificationsViewAsAccount' ) }
< span
< / span >
className = { cn (
< UserAvatar userId = { sessionPubkey } size = "small" className = "shrink-0" / >
'shrink-0 text-xs text-muted-foreground' ,
< select
showLabelAlways ? 'inline' : 'hidden sm:inline'
className = { cn (
) }
'h-9 min-w-0 flex-1 cursor-pointer rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50' ,
>
triggerClassName
{ t ( 'notificationsViewAsAccount' ) }
) }
< / span >
value = { sessionPubkey }
< div className = "flex min-w-0 flex-wrap items-center gap-1.5" >
disabled = { isAccountSessionHydrating }
{ storedAccounts . map ( ( act ) = > {
aria - label = { t ( 'notificationsViewAsAccountAria' ) }
const pk = normalizeHexPubkey ( act . pubkey )
onChange = { ( e ) = > void handlePick ( e . target . value ) }
const isActive = hexPubkeysEqual ( pk , sessionPubkey )
>
const isSwitching = switchingPubkey !== null && hexPubkeysEqual ( pk , switchingPubkey )
{ storedAccountPubkeys . map ( ( pk ) = > (
const readOnlyChip =
< option key = { pk } value = { pk } >
isActive && act . signerType === 'nip-07' && account ? . signerType === 'npub'
{ formatPubkey ( pk ) }
return (
< / option >
< button
) ) }
key = { ` ${ pk } - ${ act . signerType } ` }
< / select >
type = "button"
disabled = { busy && ! isSwitching }
aria - pressed = { isActive }
aria - label = { t ( 'accountSwitch.selectAccount' , {
pubkey : formatPubkey ( pk ) ,
defaultValue : ` Switch to ${ formatPubkey ( pk ) } `
} ) }
title = { t ( 'accountSwitch.selectAccount' , {
pubkey : formatPubkey ( pk ) ,
defaultValue : ` Switch to ${ formatPubkey ( pk ) } `
} ) }
className = { cn (
'relative shrink-0 rounded-full p-0.5 transition-[box-shadow,opacity]' ,
'ring-2 ring-offset-2 ring-offset-background' ,
isActive
? readOnlyChip
? 'ring-amber-500/90'
: 'ring-primary'
: 'ring-transparent hover:ring-muted-foreground/35' ,
busy && ! isSwitching && 'opacity-50'
) }
onClick = { ( ) = > void handlePick ( act ) }
>
< SimpleUserAvatar userId = { pk } size = "small" deferRemoteAvatar = { false } / >
{ isSwitching ? (
< span className = "absolute inset-0 flex items-center justify-center rounded-full bg-background/70" >
< Loader2 className = "size-4 animate-spin text-muted-foreground" aria - hidden / >
< / span >
) : null }
< / button >
)
} ) }
< / div >
< / div >
{ needsExtensionSync ? (
< div
className = { cn (
'rounded-md border border-amber-500/35 bg-amber-500/10 px-2.5 py-2 text-xs text-amber-950 dark:text-amber-100' ,
alignEnd && 'max-w-md self-end'
) }
>
< p className = "leading-relaxed" > { t ( 'accountSwitch.extensionSyncHint' ) } < / p >
< div className = "mt-2 flex flex-wrap gap-2" >
< Button
type = "button"
size = "sm"
variant = "secondary"
className = "h-7 text-xs"
disabled = { retryingExtension || busy }
onClick = { ( ) = > void handleRetryExtension ( ) }
>
{ retryingExtension ? (
< Loader2 className = "mr-1 size-3.5 animate-spin" aria - hidden / >
) : null }
{ t ( 'accountSwitch.extensionRetry' ) }
< / Button >
{ extensionDiffersFromSession ? (
< Button
type = "button"
size = "sm"
variant = "outline"
className = "h-7 text-xs"
disabled = { retryingExtension || busy }
onClick = { ( ) = > void adoptExtensionNip07Identity ( ) }
>
{ t ( 'nip07.useExtensionIdentity' ) }
< / Button >
) : null }
< / div >
< / div >
) : null }
< / div >
< / div >
)
)
}
}