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' @@ -13,10 +13,15 @@ import { SimpleUsername } from '../Username'
export default function AccountList({
className,
afterSwitch
afterSwitch,
closeDialog
}: {
className?: string
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 [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
@ -28,13 +33,18 @@ export default function AccountList({ @@ -28,13 +33,18 @@ export default function AccountList({
key={`${act.pubkey}-${act.signerType}`}
className={cn(
'relative rounded-lg',
isSameAccount(act, account) ? 'border border-primary' : 'clickable'
act.pubkey === account?.pubkey ? 'border border-primary' : 'clickable'
)}
onClick={() => {
if (isSameAccount(act, account)) return
setSwitchingAccount(act)
if (act.signerType === 'ncryptsec') {
closeDialog?.()
}
switchAccount(act)
.then(() => afterSwitch())
.then(() => {
if (act.signerType !== 'ncryptsec') afterSwitch()
})
.finally(() => setSwitchingAccount(null))
}}
>

2
src/components/AccountManager/index.tsx

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

21
src/components/LogoutDialog/index.tsx

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

30
src/hooks/useProfileAccordionData.tsx

@ -68,6 +68,17 @@ export function useProfileAccordionData(opts: { @@ -68,6 +68,17 @@ export function useProfileAccordionData(opts: {
const reqId = useRef(0)
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(
() => profileAccordionBundleCacheKey(relayUrls ?? []),
[relayUrls]
@ -79,7 +90,7 @@ export function useProfileAccordionData(opts: { @@ -79,7 +90,7 @@ export function useProfileAccordionData(opts: {
const runFetch = useCallback(
async (force: boolean, overrideUrls?: string[]) => {
const urls = (overrideUrls?.length ? overrideUrls : relayUrls) ?? []
const urls = (overrideUrls?.length ? overrideUrls : relayUrlsRef.current) ?? []
if (!pubkey?.trim() || !urls.length) return
const id = ++reqId.current
setLoading(true)
@ -88,8 +99,8 @@ export function useProfileAccordionData(opts: { @@ -88,8 +99,8 @@ export function useProfileAccordionData(opts: {
pubkey: pubkey.trim(),
urls,
viewerPubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays,
favoriteRelays: favoriteRelaysRef.current ?? [],
blockedRelays: blockedRelaysRef.current,
force,
onPartial: (partial) => {
if (id !== reqId.current) return
@ -103,7 +114,10 @@ export function useProfileAccordionData(opts: { @@ -103,7 +114,10 @@ export function useProfileAccordionData(opts: {
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(
@ -117,8 +131,8 @@ export function useProfileAccordionData(opts: { @@ -117,8 +131,8 @@ export function useProfileAccordionData(opts: {
pubkey: pk,
urls: deltaUrls,
viewerPubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays,
favoriteRelays: favoriteRelaysRef.current ?? [],
blockedRelays: blockedRelaysRef.current,
force: true,
onPartial: (partial) => {
if (id !== reqId.current) return
@ -143,7 +157,9 @@ export function useProfileAccordionData(opts: { @@ -143,7 +157,9 @@ export function useProfileAccordionData(opts: {
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(

3
src/i18n/locales/en.ts

@ -1458,7 +1458,8 @@ export default { @@ -1458,7 +1458,8 @@ export default {
'Log in to run this spell (it uses $me or $contacts).',
'Login failed': 'Login failed',
'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',
'Long-form Article': 'Long-form Article',
'Mailbox relays saved': 'Mailbox relays saved',

102
src/providers/NostrProvider/index.tsx

@ -111,42 +111,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -111,42 +111,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const nip07KeyMismatchToastShownRef = useRef(false)
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 [relayList, setRelayList] = useState<TRelayList | null>(null)
const [cacheRelayListEvent, setCacheRelayListEvent] = useState<Event | null>(null)
@ -167,6 +131,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -167,6 +131,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const forceNextAccountNetworkHydrateRef = useRef(false)
const manualNetworkHydrateResolveRef = useRef<(() => void) | null>(null)
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(() => {
const init = async () => {
@ -828,7 +798,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -828,7 +798,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
let attempts = 0
const maxAttempts = 6
const maxAttempts = 10
const schedule = (ms: number) => {
if (cancelled) return
@ -856,11 +826,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -856,11 +826,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return
} catch (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) {
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
}
logger.info('[NostrProvider] NIP-07 recovery retry failed', {
@ -877,7 +855,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -877,7 +855,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
cancelled = true
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 = () => {
return window.location.hash && window.location.hash.startsWith('#nostr-login')
@ -925,7 +906,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -925,7 +906,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setSigner(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) => {
@ -1138,7 +1131,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1138,7 +1131,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
!nip07KeyMismatchToastShownRef.current
) {
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)
}
@ -1227,24 +1223,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1227,24 +1223,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (!event) {
throw new Error('sign event failed')
}
// 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)
if (!validateEvent(event)) {
throw new Error('Event validation failed - invalid signature or format. Please try logging in again.')
}
return event as VerifiedEvent
}
@ -1256,11 +1237,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1256,11 +1237,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setOpenLoginDialog(true)
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 draft = normalizeDraftEventTags(draftEvent, normalizeOpts)

Loading…
Cancel
Save