From cf2611b63c85e03d8537c1f0d3bf64ca6343f8f8 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 3 Jun 2026 08:48:31 +0200 Subject: [PATCH] fix key switching with extension anon signing --- src/components/PostEditor/PostContent.tsx | 6 +- src/components/PostEditor/index.tsx | 15 - src/components/StoredAccountSwitchSelect.tsx | 300 +++++++++++++++---- src/i18n/locales/de.ts | 7 + src/i18n/locales/en.ts | 7 + src/pages/primary/SpellsPage/index.tsx | 4 +- src/providers/NostrProvider/index.tsx | 33 +- src/providers/nostr-context.tsx | 4 + 8 files changed, 296 insertions(+), 80 deletions(-) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 2578cc9e..9725962c 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -1,4 +1,5 @@ import storage from '@/services/local-storage.service' +import StoredAccountSwitchSelect from '@/components/StoredAccountSwitchSelect' import Note from '@/components/Note' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' @@ -3520,7 +3521,10 @@ export default function PostContent({ {parentEvent ? t('Reply') : isDiscussionThread ? t('Create Thread') : t('Post')} - + {open ? ( + + ) : null} + {/* Media Kind Selection Dialog */} diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index ff57d492..46b1e966 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -1,4 +1,3 @@ -import StoredAccountSwitchSelect from '@/components/StoredAccountSwitchSelect' import { Dialog, DialogContent, @@ -103,13 +102,6 @@ export default function PostEditor({ Post Editor Create a new post or reply - {open ? ( - - ) : null} {content} @@ -136,13 +128,6 @@ export default function PostEditor({ Post Editor Create a new post or reply - {open ? ( - - ) : null} {content} diff --git a/src/components/StoredAccountSwitchSelect.tsx b/src/components/StoredAccountSwitchSelect.tsx index 3a3d228a..26d7ce43 100644 --- a/src/components/StoredAccountSwitchSelect.tsx +++ b/src/components/StoredAccountSwitchSelect.tsx @@ -1,8 +1,12 @@ -import UserAvatar from '@/components/UserAvatar' -import { cn } from '@/lib/utils' +import { SimpleUserAvatar } from '@/components/UserAvatar' +import { Button } from '@/components/ui/button' 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 { 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 { toast } from 'sonner' @@ -11,99 +15,273 @@ type Props = { triggerClassName?: string /** Show the inline label on narrow viewports (e.g. full-screen post composer). */ showLabelAlways?: boolean - /** Separator under the row (e.g. post editor header). */ + /** 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 +} + +function dedupeStoredAccounts(accounts: TAccountPointer[]): TAccountPointer[] { + const seen = new Set() + 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). - * Renders nothing when there is only one stored account or no session. - * - * 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). + * 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, - triggerClassName, showLabelAlways = false, - withBottomBorder = false + withBottomBorder = false, + withTopBorder = false, + alignEnd = false }: Props) { const { t } = useTranslation() - const { pubkey, accounts, switchAccount, isAccountSessionHydrating } = useNostr() + const { + pubkey, + account, + accounts, + switchAccount, + isAccountSessionHydrating, + retryNip07SignerForPreferredAccount, + adoptExtensionNip07Identity + } = useNostr() + + const [switchingPubkey, setSwitchingPubkey] = useState(null) + const [retryingExtension, setRetryingExtension] = useState(false) + const [extensionPubkey, setExtensionPubkey] = useState(null) const sessionPubkey = useMemo(() => { const cur = pubkey?.trim() return cur ? normalizeHexPubkey(cur) : null }, [pubkey]) - const storedAccountPubkeys = useMemo(() => { - const seen = new Set() - const out: string[] = [] - for (const a of accounts) { - const raw = a.pubkey?.trim() - if (!raw) continue - const p = normalizeHexPubkey(raw) - if (!seen.has(p)) { - seen.add(p) - out.push(p) + const storedAccounts = useMemo(() => dedupeStoredAccounts(accounts), [accounts]) + + const activeStoredAccount = useMemo(() => { + if (!sessionPubkey) return null + return ( + storedAccounts.find((a) => hexPubkeysEqual(normalizeHexPubkey(a.pubkey), sessionPubkey)) ?? null + ) + }, [storedAccounts, sessionPubkey]) + + 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 - }, [accounts]) + void poll() + const id = window.setInterval(() => void poll(), 2_000) + return () => { + cancelled = true + window.clearInterval(id) + } + }, [ + needsExtensionSync, + sessionPubkey, + retryNip07SignerForPreferredAccount, + retryingExtension, + t + ]) const handlePick = useCallback( - async (v: string) => { - const target = normalizeHexPubkey(v) - if (pubkey && hexPubkeysEqual(target, normalizeHexPubkey(pubkey))) return - const nextAccount = accounts.find((a) => hexPubkeysEqual(normalizeHexPubkey(a.pubkey), target)) - if (!nextAccount) { - toast.error(t('notificationsSwitchAccountFailed')) - return - } - const switched = await switchAccount(nextAccount) - if (!switched || !hexPubkeysEqual(normalizeHexPubkey(switched), target)) { - toast.error(t('notificationsSwitchAccountFailed')) + async (nextAccount: TAccountPointer) => { + const target = normalizeHexPubkey(nextAccount.pubkey) + if (sessionPubkey && hexPubkeysEqual(target, sessionPubkey)) return + setSwitchingPubkey(target) + try { + const switched = await switchAccount(nextAccount) + if (!switched) { + toast.error(t('notificationsSwitchAccountFailed')) + return + } + 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, retryNip07SignerForPreferredAccount, 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 (
- - {t('notificationsViewAsAccount')} - - - + + {t('notificationsViewAsAccount')} + +
+ {storedAccounts.map((act) => { + const pk = normalizeHexPubkey(act.pubkey) + const isActive = hexPubkeysEqual(pk, sessionPubkey) + const isSwitching = switchingPubkey !== null && hexPubkeysEqual(pk, switchingPubkey) + const readOnlyChip = + isActive && act.signerType === 'nip-07' && account?.signerType === 'npub' + return ( + + ) + })} +
+
+ + {needsExtensionSync ? ( +
+

{t('accountSwitch.extensionSyncHint')}

+
+ + {extensionDiffersFromSession ? ( + + ) : null} +
+
+ ) : null} ) } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index c7c4c770..7e2610ec 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -995,6 +995,13 @@ export default { 'Gespeichertes Konto wechseln. Benachrichtigungen, Antworten, Zaps, Reaktionen und Boosts nutzen dieses Konto.', notificationsSwitchAccountFailed: 'Kontowechsel fehlgeschlagen. Signierer, Passwort oder Erweiterung prüfen und erneut versuchen.', + 'accountSwitch.selectAccount': 'Zu {{pubkey}} wechseln', + '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.', + 'accountSwitch.extensionRetry': 'Erweiterung erneut', + 'accountSwitch.extensionConnected': 'Erweiterung für dieses Konto verbunden.', + 'accountSwitch.extensionRetryFailed': + 'Erweiterungsschlüssel passt noch nicht. Schlüssel in der Erweiterung wechseln und erneut versuchen.', 'Show untrusted {type}': 'Untrusted {{type}} anzeigen', 'Hide untrusted {type}': 'Untrusted {{type}} ausblenden', 'Currently hiding {type} from untrusted users.': diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 5949f180..b65a5a49 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1012,6 +1012,13 @@ export default { 'Switch stored account. Notifications, replies, zaps, reactions, and boosts use this account.', notificationsSwitchAccountFailed: 'Could not switch account. Check your signer, password, or extension and try again.', + 'accountSwitch.selectAccount': 'Switch to {{pubkey}}', + '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.', + 'accountSwitch.extensionRetry': 'Retry extension', + 'accountSwitch.extensionConnected': 'Extension connected for this account.', + 'accountSwitch.extensionRetryFailed': + 'Extension key still does not match. Switch the key in your extension, then try again.', 'Show untrusted {type}': 'Show untrusted {{type}}', 'Hide untrusted {type}': 'Hide untrusted {{type}}', 'Currently hiding {type} from untrusted users.': diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index c2e2ef5d..f0bc2296 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -1062,9 +1062,9 @@ const SpellsPage = forwardRef(function SpellsPage( ) : selectedFauxSpell && fauxSubRequests.length > 0 ? ( <> {selectedFauxSpell === 'notifications' ? ( -
+
{notificationsFeedPubkey ? ( - + ) : null}
) : null} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index cfec3a10..bf2b3d1a 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1471,6 +1471,31 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { * Stored NIP-07 account pubkey no longer matches the extension (user switched keys). * Drop the stale stored NIP-07 row and sign in with whatever pubkey the extension returns now. */ + const retryNip07SignerForPreferredAccount = useEventCallback(async (): Promise => { + const preferred = storage.getCurrentAccount() + if (!preferred || preferred.signerType !== 'nip-07') return false + try { + const nip07Signer = new Nip07Signer() + await nip07Signer.init() + const extPubkey = await nip07Signer.getPublicKey() + if (!extPubkey?.trim()) return false + if (extPubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) { + return false + } + intentionalNip07ReadOnlyPubkeyRef.current = null + nip07KeyMismatchToastShownRef.current = false + login(nip07Signer, preferred) + setNip07RecoveryBump((b) => b + 1) + return true + } catch (e) { + logger.info('[NostrProvider] NIP-07 retry for preferred account failed', { + pubkeySlice: preferred.pubkey.slice(0, 12), + error: e instanceof Error ? e.message : String(e) + }) + return false + } + }) + const adoptCurrentExtensionNip07Identity = useEventCallback(async () => { try { intentionalNip07ReadOnlyPubkeyRef.current = null @@ -1560,7 +1585,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { fireNip07ExtensionKeyMismatchToast() } // Keep retrying — the extension may update its approved key after a moment. - schedule(quietReadOnly ? 8_000 : 3_000) + schedule(quietReadOnly ? 2_000 : 3_000) return } logger.info('[NostrProvider] NIP-07 recovery retry failed', { @@ -2064,6 +2089,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const removeAccountStable = useEventCallback(removeAccount) const discardLocalPrivateKeyStable = useEventCallback(discardLocalPrivateKey) const switchAccountStable = useEventCallback(switchAccount) + const retryNip07SignerForPreferredAccountStable = useEventCallback(retryNip07SignerForPreferredAccount) + const adoptExtensionNip07IdentityStable = useEventCallback(adoptCurrentExtensionNip07Identity) const nsecLoginStable = useEventCallback(nsecLogin) const ncryptsecLoginStable = useEventCallback(ncryptsecLogin) const npubLoginStable = useEventCallback(npubLogin) @@ -2113,6 +2140,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { nsec, ncryptsec, switchAccount: switchAccountStable, + retryNip07SignerForPreferredAccount: retryNip07SignerForPreferredAccountStable, + adoptExtensionNip07Identity: adoptExtensionNip07IdentityStable, nsecLogin: nsecLoginStable, ncryptsecLogin: ncryptsecLoginStable, nip07Login: nip07LoginStable, @@ -2180,6 +2209,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { signHttpAuthStable, startLogin, switchAccountStable, + retryNip07SignerForPreferredAccountStable, + adoptExtensionNip07IdentityStable, updateBlockedRelaysEventStable, updateBookmarkListEventStable, updateCacheRelayListEventStable, diff --git a/src/providers/nostr-context.tsx b/src/providers/nostr-context.tsx index 496eeb4b..ae8a235b 100644 --- a/src/providers/nostr-context.tsx +++ b/src/providers/nostr-context.tsx @@ -37,6 +37,10 @@ export type TNostrContext = { ncryptsec: string | null /** Returns the new session pubkey on success, or `null` if logout / switch failed. */ switchAccount: (account: TAccountPointer | null) => Promise + /** Reconnect NIP-07 when the extension pubkey matches the stored preferred account. */ + retryNip07SignerForPreferredAccount: () => Promise + /** Sign in with whichever pubkey the browser extension exposes now. */ + adoptExtensionNip07Identity: () => Promise nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise ncryptsecLogin: (ncryptsec: string) => Promise nip07Login: () => Promise