Browse Source

fix key switching with extension

anon signing
imwald
Silberengel 2 weeks ago
parent
commit
cf2611b63c
  1. 4
      src/components/PostEditor/PostContent.tsx
  2. 15
      src/components/PostEditor/index.tsx
  3. 300
      src/components/StoredAccountSwitchSelect.tsx
  4. 7
      src/i18n/locales/de.ts
  5. 7
      src/i18n/locales/en.ts
  6. 4
      src/pages/primary/SpellsPage/index.tsx
  7. 33
      src/providers/NostrProvider/index.tsx
  8. 4
      src/providers/nostr-context.tsx

4
src/components/PostEditor/PostContent.tsx

@ -1,4 +1,5 @@ @@ -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,6 +3521,9 @@ export default function PostContent({ @@ -3520,6 +3521,9 @@ export default function PostContent({
{parentEvent ? t('Reply') : isDiscussionThread ? t('Create Thread') : t('Post')}
</Button>
</div>
{open ? (
<StoredAccountSwitchSelect withTopBorder alignEnd className="w-full" showLabelAlways />
) : null}
{/* Media Kind Selection Dialog */}
<Dialog open={showMediaKindDialog} onOpenChange={setShowMediaKindDialog}>

15
src/components/PostEditor/index.tsx

@ -1,4 +1,3 @@ @@ -1,4 +1,3 @@
import StoredAccountSwitchSelect from '@/components/StoredAccountSwitchSelect'
import {
Dialog,
DialogContent,
@ -103,13 +102,6 @@ export default function PostEditor({ @@ -103,13 +102,6 @@ export default function PostEditor({
<SheetTitle>Post Editor</SheetTitle>
<SheetDescription>Create a new post or reply</SheetDescription>
</SheetHeader>
{open ? (
<StoredAccountSwitchSelect
withBottomBorder
className="w-full flex-wrap"
showLabelAlways
/>
) : null}
{content}
</div>
</ScrollArea>
@ -136,13 +128,6 @@ export default function PostEditor({ @@ -136,13 +128,6 @@ export default function PostEditor({
<DialogTitle>Post Editor</DialogTitle>
<DialogDescription>Create a new post or reply</DialogDescription>
</DialogHeader>
{open ? (
<StoredAccountSwitchSelect
withBottomBorder
className="w-full flex-wrap"
showLabelAlways
/>
) : null}
{content}
</div>
</ScrollArea>

300
src/components/StoredAccountSwitchSelect.tsx

@ -1,8 +1,12 @@ @@ -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 = { @@ -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<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).
* 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<string | null>(null)
const [retryingExtension, setRetryingExtension] = useState(false)
const [extensionPubkey, setExtensionPubkey] = useState<string | null>(null)
const sessionPubkey = useMemo(() => {
const cur = pubkey?.trim()
return cur ? normalizeHexPubkey(cur) : null
}, [pubkey])
const storedAccountPubkeys = useMemo(() => {
const seen = new Set<string>()
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 (
<div
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',
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('notificationsViewAsAccountAria')}
>
<span
<div
className={cn(
'shrink-0 text-xs text-muted-foreground',
showLabelAlways ? 'inline' : 'hidden sm:inline'
'flex min-w-0 flex-wrap items-center gap-2',
alignEnd && 'justify-end'
)}
>
{t('notificationsViewAsAccount')}
</span>
<UserAvatar userId={sessionPubkey} size="small" className="shrink-0" />
<select
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
)}
value={sessionPubkey}
disabled={isAccountSessionHydrating}
aria-label={t('notificationsViewAsAccountAria')}
onChange={(e) => void handlePick(e.target.value)}
>
{storedAccountPubkeys.map((pk) => (
<option key={pk} value={pk}>
{formatPubkey(pk)}
</option>
))}
</select>
<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">
{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 (
<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={() => 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>
)
}

7
src/i18n/locales/de.ts

@ -995,6 +995,13 @@ export default { @@ -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.':

7
src/i18n/locales/en.ts

@ -1012,6 +1012,13 @@ export default { @@ -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.':

4
src/pages/primary/SpellsPage/index.tsx

@ -1062,9 +1062,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1062,9 +1062,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
) : selectedFauxSpell && fauxSubRequests.length > 0 ? (
<>
{selectedFauxSpell === 'notifications' ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 px-1 pb-2 sm:justify-between">
<div className="sticky top-0 z-10 shrink-0 border-b border-border/50 bg-background/95 px-1 py-2 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
{notificationsFeedPubkey ? (
<StoredAccountSwitchSelect className="min-w-0 flex-1 sm:max-w-[min(100%,20rem)]" />
<StoredAccountSwitchSelect className="w-full" showLabelAlways />
) : null}
</div>
) : null}

33
src/providers/NostrProvider/index.tsx

@ -1471,6 +1471,31 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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<boolean> => {
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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -2180,6 +2209,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
signHttpAuthStable,
startLogin,
switchAccountStable,
retryNip07SignerForPreferredAccountStable,
adoptExtensionNip07IdentityStable,
updateBlockedRelaysEventStable,
updateBookmarkListEventStable,
updateCacheRelayListEventStable,

4
src/providers/nostr-context.tsx

@ -37,6 +37,10 @@ export type TNostrContext = { @@ -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<string | null>
/** Reconnect NIP-07 when the extension pubkey matches the stored preferred account. */
retryNip07SignerForPreferredAccount: () => Promise<boolean>
/** Sign in with whichever pubkey the browser extension exposes now. */
adoptExtensionNip07Identity: () => Promise<void>
nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise<string>
ncryptsecLogin: (ncryptsec: string) => Promise<string>
nip07Login: () => Promise<string>

Loading…
Cancel
Save