You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
385 lines
14 KiB
385 lines
14 KiB
import { AnonUserAvatar } from '@/components/AnonUserAvatar' |
|
import { SimpleUserAvatar } from '@/components/UserAvatar' |
|
import { Button } from '@/components/ui/button' |
|
import { |
|
accountPointerKey, |
|
createAnonAccountPointer, |
|
isAnonAccount, |
|
isRedundantAccountPick, |
|
listSwitchableAccounts |
|
} from '@/lib/account' |
|
import { accountPubkeyToHex, 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 storage from '@/services/local-storage.service' |
|
import type { TAccountPointer } from '@/types' |
|
import { Loader2, X } from 'lucide-react' |
|
import { useCallback, useEffect, useMemo, useState } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { toast } from 'sonner' |
|
|
|
type Props = { |
|
className?: string |
|
triggerClassName?: string |
|
/** Show the inline label on narrow viewports (e.g. full-screen post composer). */ |
|
showLabelAlways?: boolean |
|
/** Separator under the row. */ |
|
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 |
|
/** Post composer: keep clicks from bubbling to the dialog/sheet dismiss layer. */ |
|
inComposer?: boolean |
|
} |
|
|
|
const EXTENSION_SYNC_HINT_DISMISSED_PREFIX = 'extensionSyncHintDismissed:' |
|
const anonAccount = createAnonAccountPointer() |
|
|
|
function readExtensionSyncHintDismissed(pubkey: string | null): boolean { |
|
if (!pubkey || typeof window === 'undefined') return false |
|
try { |
|
return sessionStorage.getItem(`${EXTENSION_SYNC_HINT_DISMISSED_PREFIX}${pubkey}`) === 'true' |
|
} catch { |
|
return false |
|
} |
|
} |
|
|
|
/** |
|
* 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. |
|
*/ |
|
export default function StoredAccountSwitchSelect({ |
|
className, |
|
showLabelAlways = false, |
|
withBottomBorder = false, |
|
withTopBorder = false, |
|
alignEnd = false, |
|
inComposer = false |
|
}: Props) { |
|
const { t } = useTranslation() |
|
const { |
|
pubkey, |
|
account, |
|
accounts, |
|
isAnonSession, |
|
switchAccount, |
|
isAccountSessionHydrating, |
|
retryNip07SignerForPreferredAccount, |
|
adoptExtensionNip07Identity |
|
} = useNostr() |
|
|
|
const [switchingKey, setSwitchingKey] = useState<string | null>(null) |
|
const [retryingExtension, setRetryingExtension] = useState(false) |
|
const [extensionPubkey, setExtensionPubkey] = useState<string | null>(null) |
|
const [extensionSyncHintDismissed, setExtensionSyncHintDismissed] = useState(false) |
|
|
|
const sessionPubkey = useMemo(() => { |
|
if (isAnonSession) return null |
|
const cur = pubkey?.trim() |
|
return cur ? normalizeHexPubkey(cur) : null |
|
}, [pubkey, isAnonSession]) |
|
|
|
const storedAccounts = useMemo(() => listSwitchableAccounts(accounts), [accounts]) |
|
|
|
const needsExtensionSync = useMemo(() => { |
|
if (!sessionPubkey || !account || account.signerType !== 'npub') return false |
|
return accounts.some( |
|
(a) => |
|
a.signerType === 'nip-07' && |
|
hexPubkeysEqual(normalizeHexPubkey(a.pubkey), sessionPubkey) |
|
) |
|
}, [accounts, sessionPubkey, account]) |
|
|
|
const extensionDiffersFromSession = useMemo(() => { |
|
if (!extensionPubkey || !sessionPubkey) return false |
|
return !hexPubkeysEqual(normalizeHexPubkey(extensionPubkey), sessionPubkey) |
|
}, [extensionPubkey, sessionPubkey]) |
|
|
|
useEffect(() => { |
|
setExtensionSyncHintDismissed(readExtensionSyncHintDismissed(sessionPubkey)) |
|
}, [sessionPubkey]) |
|
|
|
useEffect(() => { |
|
if (!needsExtensionSync) { |
|
setExtensionPubkey(null) |
|
return |
|
} |
|
let cancelled = false |
|
void (async () => { |
|
try { |
|
const nip07Signer = new Nip07Signer() |
|
await nip07Signer.init() |
|
const pk = await nip07Signer.getPublicKey() |
|
if (!cancelled && pk?.trim()) setExtensionPubkey(pk) |
|
} catch { |
|
if (!cancelled) setExtensionPubkey(null) |
|
} |
|
})() |
|
return () => { |
|
cancelled = true |
|
} |
|
}, [needsExtensionSync]) |
|
|
|
useEffect(() => { |
|
if (!needsExtensionSync || !extensionPubkey || !sessionPubkey) return |
|
if (!hexPubkeysEqual(normalizeHexPubkey(extensionPubkey), sessionPubkey)) return |
|
let cancelled = false |
|
void (async () => { |
|
const ok = await retryNip07SignerForPreferredAccount() |
|
if (!cancelled && ok) { |
|
toast.success(t('accountSwitch.extensionConnected')) |
|
} |
|
})() |
|
return () => { |
|
cancelled = true |
|
} |
|
}, [ |
|
needsExtensionSync, |
|
extensionPubkey, |
|
sessionPubkey, |
|
retryNip07SignerForPreferredAccount, |
|
t |
|
]) |
|
|
|
const handlePick = useCallback( |
|
async (nextAccount: TAccountPointer) => { |
|
if (isAnonAccount(nextAccount)) { |
|
if (isAnonSession) return |
|
setSwitchingKey(accountPointerKey(nextAccount)) |
|
try { |
|
await switchAccount(nextAccount) |
|
} finally { |
|
setSwitchingKey(null) |
|
} |
|
return |
|
} |
|
|
|
const target = accountPubkeyToHex(nextAccount.pubkey) |
|
if (isRedundantAccountPick(nextAccount, account)) { |
|
if (account?.signerType === 'npub' && nextAccount.signerType === 'nip-07') { |
|
setSwitchingKey(accountPointerKey(nextAccount)) |
|
try { |
|
await switchAccount(nextAccount) |
|
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 { |
|
const switched = await switchAccount(nextAccount) |
|
if (!switched) { |
|
toast.error(t('notificationsSwitchAccountFailed')) |
|
return |
|
} |
|
if (target && !hexPubkeysEqual(switched, target)) { |
|
toast.error(t('notificationsSwitchAccountFailed')) |
|
return |
|
} |
|
if (inComposer && nextAccount.signerType === 'nip-07') { |
|
const current = storage.getCurrentAccount() |
|
if (current?.signerType === 'npub' && hexPubkeysEqual(current.pubkey, switched)) { |
|
toast.error(t('accountSwitch.composerExtensionMismatch')) |
|
} |
|
} |
|
} finally { |
|
setSwitchingKey(null) |
|
} |
|
}, |
|
[account, switchAccount, retryNip07SignerForPreferredAccount, t, inComposer, isAnonSession] |
|
) |
|
|
|
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]) |
|
|
|
const dismissExtensionSyncHint = useCallback(() => { |
|
setExtensionSyncHintDismissed(true) |
|
if (!sessionPubkey) return |
|
try { |
|
sessionStorage.setItem(`${EXTENSION_SYNC_HINT_DISMISSED_PREFIX}${sessionPubkey}`, 'true') |
|
} catch { |
|
// ignore quota or private browsing |
|
} |
|
}, [sessionPubkey]) |
|
|
|
const showSwitcher = |
|
inComposer || isAnonSession || storedAccounts.length > 1 || storedAccounts.length > 0 |
|
if (!showSwitcher) return null |
|
if (!inComposer && storedAccounts.length <= 1 && !isAnonSession) return null |
|
|
|
const busy = isAccountSessionHydrating || switchingKey !== null |
|
const anonActive = isAnonSession |
|
const anonSwitching = switchingKey === accountPointerKey(anonAccount) |
|
|
|
return ( |
|
<div |
|
className={cn( |
|
'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', |
|
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 |
|
)} |
|
role="group" |
|
aria-label={t('notificationsViewAsAccount')} |
|
> |
|
<div |
|
className={cn( |
|
'flex min-w-0 flex-wrap items-center gap-2', |
|
alignEnd && 'justify-end' |
|
)} |
|
> |
|
<span |
|
className={cn( |
|
'shrink-0 text-xs text-muted-foreground', |
|
showLabelAlways ? 'inline' : 'hidden sm:inline' |
|
)} |
|
> |
|
{t('notificationsViewAsAccount')} |
|
</span> |
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5"> |
|
<button |
|
type="button" |
|
disabled={busy && !anonSwitching} |
|
aria-pressed={anonActive} |
|
aria-label={t('accountSwitch.selectAnon')} |
|
title={t('accountSwitch.anonHint')} |
|
className={cn( |
|
'relative shrink-0 rounded-full p-0.5 transition-[box-shadow,opacity]', |
|
'ring-2 ring-offset-2 ring-offset-background', |
|
anonActive ? 'ring-primary' : 'ring-transparent hover:ring-muted-foreground/35', |
|
busy && !anonSwitching && 'opacity-50' |
|
)} |
|
onClick={(e) => { |
|
if (inComposer) e.stopPropagation() |
|
void handlePick(anonAccount) |
|
}} |
|
> |
|
<AnonUserAvatar size="small" /> |
|
{anonSwitching ? ( |
|
<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> |
|
{storedAccounts.map((act) => { |
|
const pk = normalizeHexPubkey(act.pubkey) |
|
const isActive = |
|
!isAnonSession && |
|
sessionPubkey != null && |
|
hexPubkeysEqual(pk, sessionPubkey) && |
|
(account?.signerType === act.signerType || |
|
(account?.signerType === 'npub' && |
|
act.signerType === 'nip-07' && |
|
needsExtensionSync)) |
|
const isSwitching = switchingKey === accountPointerKey(act) |
|
const readOnlyChip = isActive && needsExtensionSync |
|
return ( |
|
<button |
|
key={`${pk}-${act.signerType}`} |
|
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={(e) => { |
|
if (inComposer) e.stopPropagation() |
|
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 && !extensionSyncHintDismissed ? ( |
|
<div |
|
className={cn( |
|
'relative rounded-md border border-amber-500/35 bg-amber-500/10 px-2.5 py-2 pr-8 text-xs text-amber-950 dark:text-amber-100', |
|
alignEnd && 'max-w-md self-end' |
|
)} |
|
> |
|
<button |
|
type="button" |
|
className="absolute right-1 top-1 rounded-md p-1 text-amber-800/80 hover:bg-amber-500/15 hover:text-amber-950 dark:text-amber-200/80 dark:hover:bg-amber-500/20 dark:hover:text-amber-50" |
|
aria-label={t('Dismiss')} |
|
onClick={dismissExtensionSyncHint} |
|
> |
|
<X className="size-3.5" aria-hidden /> |
|
</button> |
|
<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> |
|
) |
|
}
|
|
|