Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
48cffb2008
  1. 16
      src/components/AccountList/index.tsx
  2. 2
      src/components/AccountManager/index.tsx
  3. 21
      src/components/LogoutDialog/index.tsx
  4. 30
      src/hooks/useProfileAccordionData.tsx
  5. 3
      src/i18n/locales/en.ts
  6. 102
      src/providers/NostrProvider/index.tsx

16
src/components/AccountList/index.tsx

@ -13,10 +13,15 @@ import { SimpleUsername } from '../Username'
export default function AccountList({ export default function AccountList({
className, className,
afterSwitch afterSwitch,
closeDialog
}: { }: {
className?: string className?: string
afterSwitch: () => void afterSwitch: () => void
/** Called before switching to an ncryptsec account so the parent dialog can
* close before the password prompt opens (avoids two simultaneous Radix
* dialogs fighting over focus trapping). */
closeDialog?: () => void
}) { }) {
const { accounts, account, switchAccount, removeAccount } = useNostr() const { accounts, account, switchAccount, removeAccount } = useNostr()
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null) const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
@ -28,13 +33,18 @@ export default function AccountList({
key={`${act.pubkey}-${act.signerType}`} key={`${act.pubkey}-${act.signerType}`}
className={cn( className={cn(
'relative rounded-lg', 'relative rounded-lg',
isSameAccount(act, account) ? 'border border-primary' : 'clickable' act.pubkey === account?.pubkey ? 'border border-primary' : 'clickable'
)} )}
onClick={() => { onClick={() => {
if (isSameAccount(act, account)) return if (isSameAccount(act, account)) return
setSwitchingAccount(act) setSwitchingAccount(act)
if (act.signerType === 'ncryptsec') {
closeDialog?.()
}
switchAccount(act) switchAccount(act)
.then(() => afterSwitch()) .then(() => {
if (act.signerType !== 'ncryptsec') afterSwitch()
})
.finally(() => setSwitchingAccount(null)) .finally(() => setSwitchingAccount(null))
}} }}
> >

2
src/components/AccountManager/index.tsx

@ -113,7 +113,7 @@ function AccountManagerNav({
<div className="text-center text-muted-foreground text-sm font-semibold"> <div className="text-center text-muted-foreground text-sm font-semibold">
{t('Logged in Accounts')} {t('Logged in Accounts')}
</div> </div>
<AccountList className="mt-4" afterSwitch={() => close?.()} /> <AccountList className="mt-4" afterSwitch={() => close?.()} closeDialog={() => close?.()} />
</div> </div>
</> </>
)} )}

21
src/components/LogoutDialog/index.tsx

@ -30,7 +30,12 @@ export default function LogoutDialog({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { account, removeAccount } = useNostr() const { account, switchAccount } = useNostr()
const handleLogout = () => {
setOpen(false)
void switchAccount(null)
}
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
@ -46,12 +51,8 @@ export default function LogoutDialog({
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => { onClick={handleLogout}
if (account) { disabled={!account}
setOpen(false)
removeAccount(account)
}
}}
className="w-full" className="w-full"
> >
{t('Logout')} {t('Logout')}
@ -73,11 +74,7 @@ export default function LogoutDialog({
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel> <AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
variant="destructive" variant="destructive"
onClick={() => { onClick={handleLogout}
if (account) {
removeAccount(account)
}
}}
> >
{t('Logout')} {t('Logout')}
</AlertDialogAction> </AlertDialogAction>

30
src/hooks/useProfileAccordionData.tsx

@ -68,6 +68,17 @@ export function useProfileAccordionData(opts: {
const reqId = useRef(0) const reqId = useRef(0)
const lastSuccessfulRelayUrlsRef = useRef<string[]>([]) const lastSuccessfulRelayUrlsRef = useRef<string[]>([])
// Keep refs so callbacks don't get recreated when these arrays change reference.
// Including live array references as useCallback deps causes the useLayoutEffect
// to re-fire and increment reqId, cancelling every in-flight fetch before it
// can commit its result — the accordion never shows data.
const relayUrlsRef = useRef(relayUrls)
relayUrlsRef.current = relayUrls
const favoriteRelaysRef = useRef(favoriteRelays)
favoriteRelaysRef.current = favoriteRelays
const blockedRelaysRef = useRef(blockedRelays)
blockedRelaysRef.current = blockedRelays
const relayKey = useMemo( const relayKey = useMemo(
() => profileAccordionBundleCacheKey(relayUrls ?? []), () => profileAccordionBundleCacheKey(relayUrls ?? []),
[relayUrls] [relayUrls]
@ -79,7 +90,7 @@ export function useProfileAccordionData(opts: {
const runFetch = useCallback( const runFetch = useCallback(
async (force: boolean, overrideUrls?: string[]) => { async (force: boolean, overrideUrls?: string[]) => {
const urls = (overrideUrls?.length ? overrideUrls : relayUrls) ?? [] const urls = (overrideUrls?.length ? overrideUrls : relayUrlsRef.current) ?? []
if (!pubkey?.trim() || !urls.length) return if (!pubkey?.trim() || !urls.length) return
const id = ++reqId.current const id = ++reqId.current
setLoading(true) setLoading(true)
@ -88,8 +99,8 @@ export function useProfileAccordionData(opts: {
pubkey: pubkey.trim(), pubkey: pubkey.trim(),
urls, urls,
viewerPubkey, viewerPubkey,
favoriteRelays: favoriteRelays ?? [], favoriteRelays: favoriteRelaysRef.current ?? [],
blockedRelays, blockedRelays: blockedRelaysRef.current,
force, force,
onPartial: (partial) => { onPartial: (partial) => {
if (id !== reqId.current) return if (id !== reqId.current) return
@ -103,7 +114,10 @@ export function useProfileAccordionData(opts: {
if (id === reqId.current) setLoading(false) if (id === reqId.current) setLoading(false)
} }
}, },
[pubkey, relayUrls, viewerPubkey, favoriteRelays, blockedRelays] // relayUrls, favoriteRelays, and blockedRelays are read via refs — intentionally
// excluded from deps to prevent callback churn that cancels in-flight requests.
// eslint-disable-next-line react-hooks/exhaustive-deps
[pubkey, viewerPubkey]
) )
const runMergeFetch = useCallback( const runMergeFetch = useCallback(
@ -117,8 +131,8 @@ export function useProfileAccordionData(opts: {
pubkey: pk, pubkey: pk,
urls: deltaUrls, urls: deltaUrls,
viewerPubkey, viewerPubkey,
favoriteRelays: favoriteRelays ?? [], favoriteRelays: favoriteRelaysRef.current ?? [],
blockedRelays, blockedRelays: blockedRelaysRef.current,
force: true, force: true,
onPartial: (partial) => { onPartial: (partial) => {
if (id !== reqId.current) return if (id !== reqId.current) return
@ -143,7 +157,9 @@ export function useProfileAccordionData(opts: {
if (id === reqId.current) setLoading(false) if (id === reqId.current) setLoading(false)
} }
}, },
[pubkey, viewerPubkey, favoriteRelays, blockedRelays] // favoriteRelays and blockedRelays are read via refs — see runFetch comment.
// eslint-disable-next-line react-hooks/exhaustive-deps
[pubkey, viewerPubkey]
) )
const refresh = useCallback( const refresh = useCallback(

3
src/i18n/locales/en.ts

@ -1458,7 +1458,8 @@ export default {
'Log in to run this spell (it uses $me or $contacts).', 'Log in to run this spell (it uses $me or $contacts).',
'Login failed': 'Login failed', 'Login failed': 'Login failed',
'nip07.extensionKeyMismatch': 'nip07.extensionKeyMismatch':
'Your browser wallet is using a different key than this saved account. Select the matching key in the extension, or log in to add this wallet as an account. Retrying will not help until the keys match.', "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
'nip07.reloadPage': 'Reload page',
'Login to configure RSS feeds': 'Login to configure RSS feeds', 'Login to configure RSS feeds': 'Login to configure RSS feeds',
'Long-form Article': 'Long-form Article', 'Long-form Article': 'Long-form Article',
'Mailbox relays saved': 'Mailbox relays saved', 'Mailbox relays saved': 'Mailbox relays saved',

102
src/providers/NostrProvider/index.tsx

@ -111,42 +111,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const nip07KeyMismatchToastShownRef = useRef(false) const nip07KeyMismatchToastShownRef = useRef(false)
const [profile, setProfile] = useState<TProfile | null>(null) const [profile, setProfile] = useState<TProfile | null>(null)
// Cleanup on page unload to prevent extension UI issues
useEffect(() => {
const handleBeforeUnload = () => {
// Try to clean up any pending operations
if (signer && 'disconnect' in signer) {
try {
(signer as any).disconnect()
} catch (error) {
logger.warn('Failed to disconnect signer:', error)
}
}
}
const handleUnload = () => {
// Additional cleanup for extensions that might leave UI elements
try {
// Clear any pending timeouts or intervals
if (window.nostr && typeof window.nostr === 'object') {
// Some extensions might have cleanup methods
if ('cleanup' in window.nostr && typeof window.nostr.cleanup === 'function') {
window.nostr.cleanup()
}
}
} catch (error) {
logger.warn('Extension cleanup failed:', error)
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('unload', handleUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('unload', handleUnload)
}
}, [signer])
const [profileEvent, setProfileEvent] = useState<Event | null>(null) const [profileEvent, setProfileEvent] = useState<Event | null>(null)
const [relayList, setRelayList] = useState<TRelayList | null>(null) const [relayList, setRelayList] = useState<TRelayList | null>(null)
const [cacheRelayListEvent, setCacheRelayListEvent] = useState<Event | null>(null) const [cacheRelayListEvent, setCacheRelayListEvent] = useState<Event | null>(null)
@ -167,6 +131,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const forceNextAccountNetworkHydrateRef = useRef(false) const forceNextAccountNetworkHydrateRef = useRef(false)
const manualNetworkHydrateResolveRef = useRef<(() => void) | null>(null) const manualNetworkHydrateResolveRef = useRef<(() => void) | null>(null)
const [accountNetworkHydrateBump, setAccountNetworkHydrateBump] = useState(0) const [accountNetworkHydrateBump, setAccountNetworkHydrateBump] = useState(0)
/**
* Bumped by {@link switchAccount} after it persists the intended target to storage following
* an npub fallback. This re-triggers the NIP-07 recovery loop so it can reconnect as soon
* as the user updates their browser extension.
*/
const [nip07RecoveryBump, setNip07RecoveryBump] = useState(0)
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@ -828,7 +798,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
let cancelled = false let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
let attempts = 0 let attempts = 0
const maxAttempts = 6 const maxAttempts = 10
const schedule = (ms: number) => { const schedule = (ms: number) => {
if (cancelled) return if (cancelled) return
@ -856,11 +826,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return return
} catch (error) { } catch (error) {
if (isNip07SignerPubkeyMismatchError(error)) { if (isNip07SignerPubkeyMismatchError(error)) {
logger.info('[NostrProvider] NIP-07 recovery: extension key mismatch on attempt', {
attempts,
wantedPubkey: preferred.pubkey.slice(0, 12)
})
if (!nip07KeyMismatchToastShownRef.current) { if (!nip07KeyMismatchToastShownRef.current) {
nip07KeyMismatchToastShownRef.current = true nip07KeyMismatchToastShownRef.current = true
toast.error(t('nip07.extensionKeyMismatch'), { duration: 12_000 }) toast.error(t('nip07.extensionKeyMismatch'), {
duration: 20_000,
action: { label: t('nip07.reloadPage'), onClick: () => window.location.reload() }
})
} }
attempts = maxAttempts // Keep retrying — the extension may update its approved key after a moment.
schedule(3_000)
return return
} }
logger.info('[NostrProvider] NIP-07 recovery retry failed', { logger.info('[NostrProvider] NIP-07 recovery retry failed', {
@ -877,7 +855,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
cancelled = true cancelled = true
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
} }
}, [account]) // nip07RecoveryBump is incremented by switchAccount after it updates storage following an
// npub fallback, so the loop re-fires with the correct preferred account.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account, nip07RecoveryBump])
const hasNostrLoginHash = () => { const hasNostrLoginHash = () => {
return window.location.hash && window.location.hash.startsWith('#nostr-login') return window.location.hash && window.location.hash.startsWith('#nostr-login')
@ -925,7 +906,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setSigner(null) setSigner(null)
return null return null
} }
return await loginWithAccountPointer(act) const result = await loginWithAccountPointer(act)
// If loginWithAccountPointer fell back to read-only npub it skips storage.switchAccount.
// Persist the user's intent here so:
// • session restore on refresh targets the right account, and
// • the NIP-07 recovery loop (which reads storage.getCurrentAccount) can fire.
if (result !== null && storage.getCurrentAccount()?.pubkey !== act.pubkey) {
const storedFull = storage.findAccount(act)
if (storedFull) {
storage.switchAccount(storedFull)
setNip07RecoveryBump((b) => b + 1)
}
}
return result
} }
const finishNcryptsecPasswordPrompt = useCallback((password: string | null) => { const finishNcryptsecPasswordPrompt = useCallback((password: string | null) => {
@ -1138,7 +1131,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
!nip07KeyMismatchToastShownRef.current !nip07KeyMismatchToastShownRef.current
) { ) {
nip07KeyMismatchToastShownRef.current = true nip07KeyMismatchToastShownRef.current = true
toast.error(t('nip07.extensionKeyMismatch'), { duration: 12_000 }) toast.error(t('nip07.extensionKeyMismatch'), {
duration: 20_000,
action: { label: t('nip07.reloadPage'), onClick: () => window.location.reload() }
})
} }
return fallbackToReadOnlyNpub(storedAccount.pubkey, err) return fallbackToReadOnlyNpub(storedAccount.pubkey, err)
} }
@ -1227,24 +1223,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (!event) { if (!event) {
throw new Error('sign event failed') throw new Error('sign event failed')
} }
if (!validateEvent(event)) {
// Debug: Log the signed event
logger.debug('Signed event:', {
id: event.id,
pubkey: event.pubkey,
sig: event.sig,
content: event.content.substring(0, 100) + '...',
tags: event.tags,
created_at: event.created_at
})
// Validate the event before publishing
const isValid = validateEvent(event)
if (!isValid) {
logger.error('Event validation failed:', event)
throw new Error('Event validation failed - invalid signature or format. Please try logging in again.') throw new Error('Event validation failed - invalid signature or format. Please try logging in again.')
} }
return event as VerifiedEvent return event as VerifiedEvent
} }
@ -1257,11 +1238,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
throw new LoginRequiredError() throw new LoginRequiredError()
} }
// Validate account state before publishing
if (!account.pubkey || account.pubkey.length !== 64) {
throw new Error('Invalid account state - pubkey is missing or invalid')
}
const normalizeOpts = { addClientTag: options.addClientTag } const normalizeOpts = { addClientTag: options.addClientTag }
const draft = normalizeDraftEventTags(draftEvent, normalizeOpts) const draft = normalizeDraftEventTags(draftEvent, normalizeOpts)
let event: Event let event: Event

Loading…
Cancel
Save