Browse Source

refactor refresh

imwald
Silberengel 2 weeks ago
parent
commit
e13d3cfada
  1. 14
      src/components/AccountList/index.tsx
  2. 16
      src/components/AccountQuickSwitchMenuItems.tsx
  3. 16
      src/components/FollowButton/index.tsx
  4. 41
      src/components/HelpAndAccountMenu.tsx
  5. 12
      src/components/MuteButton/index.tsx
  6. 51
      src/components/Nip07ExtensionKeyMismatchToast/index.tsx
  7. 12
      src/components/NoteStats/LikeButton.tsx
  8. 5
      src/components/NoteStats/ReplyButton.tsx
  9. 9
      src/components/NoteStats/RepostButton.tsx
  10. 4
      src/components/PostEditor/PostContent.tsx
  11. 86
      src/components/StoredAccountSwitchSelect.tsx
  12. 1
      src/hooks/index.tsx
  13. 6
      src/hooks/useFetchProfile.tsx
  14. 20
      src/hooks/useSignGatedControl.ts
  15. 2
      src/i18n/locales/de.ts
  16. 2
      src/i18n/locales/en.ts
  17. 24
      src/lib/account.test.ts
  18. 35
      src/lib/account.ts
  19. 28
      src/lib/nip07-extension-key-mismatch-toast.tsx
  20. 12
      src/lib/pubkey-nip07.test.ts
  21. 17
      src/lib/pubkey.ts
  22. 10
      src/pages/primary/SpellsPage/index.tsx
  23. 298
      src/providers/NostrProvider/index.tsx
  24. 20
      src/providers/NostrProvider/nip-07.signer.ts
  25. 2
      src/types/index.d.ts

14
src/components/AccountList/index.tsx

@ -30,7 +30,6 @@ export default function AccountList({
accounts, accounts,
account, account,
switchAccount, switchAccount,
viewAccountAsReadOnly,
removeAccount, removeAccount,
retryNip07SignerForPreferredAccount retryNip07SignerForPreferredAccount
} = useNostr() } = useNostr()
@ -58,9 +57,14 @@ export default function AccountList({
if (isRedundantAccountPick(act, account)) { if (isRedundantAccountPick(act, account)) {
if (account?.signerType === 'npub' && act.signerType === 'nip-07') { if (account?.signerType === 'npub' && act.signerType === 'nip-07') {
setSwitchingAccount(act) setSwitchingAccount(act)
const switched = await switchAccount(act)
if (switched) {
afterSwitch()
} else {
const ok = await retryNip07SignerForPreferredAccount() const ok = await retryNip07SignerForPreferredAccount()
if (ok) toast.success(t('accountSwitch.extensionConnected')) if (ok) toast.success(t('accountSwitch.extensionConnected'))
else toast.error(t('accountSwitch.extensionRetryFailed')) else toast.error(t('accountSwitch.extensionRetryFailed'))
}
setSwitchingAccount(null) setSwitchingAccount(null)
} }
return return
@ -70,13 +74,7 @@ export default function AccountList({
closeDialog?.() closeDialog?.()
} }
try { try {
const needsWriteSigner = const switched = await switchAccount(act)
act.signerType === 'nsec' ||
act.signerType === 'ncryptsec' ||
act.signerType === 'bunker'
const switched = needsWriteSigner
? await switchAccount(act)
: await viewAccountAsReadOnly(act)
if (!switched) { if (!switched) {
toast.error(t('notificationsSwitchAccountFailed')) toast.error(t('notificationsSwitchAccountFailed'))
return return

16
src/components/AccountQuickSwitchMenuItems.tsx

@ -25,7 +25,6 @@ export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?:
accounts, accounts,
account, account,
switchAccount, switchAccount,
viewAccountAsReadOnly,
retryNip07SignerForPreferredAccount retryNip07SignerForPreferredAccount
} = useNostr() } = useNostr()
const rows = listSwitchableAccounts(accounts) const rows = listSwitchableAccounts(accounts)
@ -35,23 +34,22 @@ export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?:
const handleSwitch = async (act: TAccountPointer) => { const handleSwitch = async (act: TAccountPointer) => {
if (isRedundantAccountPick(act, account)) { if (isRedundantAccountPick(act, account)) {
if (account?.signerType === 'npub' && act.signerType === 'nip-07') { if (account?.signerType === 'npub' && act.signerType === 'nip-07') {
const switched = await switchAccount(act)
if (switched) {
onAfterSwitch?.()
return
}
const ok = await retryNip07SignerForPreferredAccount() const ok = await retryNip07SignerForPreferredAccount()
if (ok) { if (ok) {
toast.success(t('accountSwitch.extensionConnected')) toast.success(t('accountSwitch.extensionConnected'))
onAfterSwitch?.() onAfterSwitch?.()
} else { } else {
toast.error(t('accountSwitch.extensionRetryFailed')) toast.error(t('accountSwitch.extensionUnavailable'))
} }
} }
return return
} }
const needsWriteSigner = const switched = await switchAccount(act)
act.signerType === 'nsec' ||
act.signerType === 'ncryptsec' ||
act.signerType === 'bunker'
const switched = needsWriteSigner
? await switchAccount(act)
: await viewAccountAsReadOnly(act)
if (!switched) { if (!switched) {
toast.error(t('notificationsSwitchAccountFailed')) toast.error(t('notificationsSwitchAccountFailed'))
return return

16
src/components/FollowButton/index.tsx

@ -14,6 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useFollowListOptional } from '@/providers/follow-list-context' import { useFollowListOptional } from '@/providers/follow-list-context'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -22,6 +23,7 @@ import { toast } from 'sonner'
export default function FollowButton({ pubkey }: { pubkey: string }) { export default function FollowButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: accountPubkey, checkLogin } = useNostr() const { pubkey: accountPubkey, checkLogin } = useNostr()
const { canSignEvents, signControlProps } = useSignGatedControl()
const followList = useFollowListOptional() const followList = useFollowListOptional()
const { mutePubkeySet, unmutePubkey } = useMuteList() const { mutePubkeySet, unmutePubkey } = useMuteList()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
@ -31,7 +33,9 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey]) const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey]) const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey])
if (!followList || !accountPubkey || (pubkey && pubkey === accountPubkey)) return null if (!followList || !accountPubkey || !canSignEvents || (pubkey && pubkey === accountPubkey)) {
return null
}
const { follow, unfollow } = followList const { follow, unfollow } = followList
@ -92,7 +96,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
<Button <Button
className="rounded-full min-w-28 max-w-full text-destructive whitespace-normal break-words px-3" className="rounded-full min-w-28 max-w-full text-destructive whitespace-normal break-words px-3"
variant="secondary" variant="secondary"
disabled={updating} {...signControlProps({ disabled: updating })}
> >
{updating ? ( {updating ? (
<Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden />
@ -125,7 +129,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
<Button <Button
className="rounded-full min-w-28" className="rounded-full min-w-28"
variant={hover ? 'destructive' : 'secondary'} variant={hover ? 'destructive' : 'secondary'}
disabled={updating} {...signControlProps({ disabled: updating })}
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)} onMouseLeave={() => setHover(false)}
> >
@ -154,7 +158,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
) : ( ) : (
<Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}> <Button
className="rounded-full min-w-28"
onClick={handleFollow}
{...signControlProps({ disabled: updating })}
>
{updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Follow')} {updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Follow')}
</Button> </Button>
) )

41
src/components/HelpAndAccountMenu.tsx

@ -11,7 +11,14 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' import {
accountPubkeyToHex,
formatPubkey,
formatNpub,
generateImageByPubkey,
hexPubkeysEqual,
pubkeyToNpub
} from '@/lib/pubkey'
import { isVideo } from '@/lib/url' import { isVideo } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { openBrowseCacheFromRegistry } from '@/contexts/cache-browser-context' import { openBrowseCacheFromRegistry } from '@/contexts/cache-browser-context'
@ -27,6 +34,20 @@ import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react'
import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { TProfile } from '@/types'
/** Profile for the badge only when it belongs to the active session pubkey (avoids stale name/avatar). */
function profileForActivePubkey(
pubkey: string | undefined,
nostrProfile: TProfile | null,
fetchedProfile: TProfile | null
): TProfile | null {
const pk = pubkey ? accountPubkeyToHex(pubkey) : null
if (!pk) return null
if (fetchedProfile && hexPubkeysEqual(fetchedProfile.pubkey, pk)) return fetchedProfile
if (nostrProfile && hexPubkeysEqual(nostrProfile.pubkey, pk)) return nostrProfile
return null
}
const titlebarAccountMenuContentClassName = const titlebarAccountMenuContentClassName =
'z-[220] w-[min(18rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain' 'z-[220] w-[min(18rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain'
@ -92,6 +113,10 @@ function SidebarAccountMenu({
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const pubkey = account?.pubkey const pubkey = account?.pubkey
const { profile: fetchedProfile } = useFetchProfile(pubkey) const { profile: fetchedProfile } = useFetchProfile(pubkey)
const resolvedProfile = useMemo(
() => profileForActivePubkey(pubkey, profile, fetchedProfile),
[pubkey, profile, fetchedProfile]
)
const active = useMemo(() => current === 'profile' && display, [display, current]) const active = useMemo(() => current === 'profile' && display, [display, current])
if (!pubkey) return null if (!pubkey) return null
@ -99,8 +124,9 @@ function SidebarAccountMenu({
const defaultAvatar = generateImageByPubkey(pubkey) const defaultAvatar = generateImageByPubkey(pubkey)
const npub = pubkeyToNpub(pubkey) const npub = pubkeyToNpub(pubkey)
const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey) const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey)
const resolvedProfile = fetchedProfile ?? profile const { username, avatar } = resolvedProfile
const { username, avatar } = resolvedProfile || { username: fallbackUsername, avatar: defaultAvatar } ? { username: resolvedProfile.username, avatar: resolvedProfile.avatar ?? defaultAvatar }
: { username: fallbackUsername, avatar: defaultAvatar }
return ( return (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
@ -121,7 +147,7 @@ function SidebarAccountMenu({
<video src={avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline /> <video src={avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
</div> </div>
) : ( ) : (
<Avatar className="size-8 shrink-0"> <Avatar className="size-8 shrink-0" key={pubkey}>
<AvatarImage src={avatar || defaultAvatar} className="object-cover object-center" /> <AvatarImage src={avatar || defaultAvatar} className="object-cover object-center" />
<AvatarFallback delayMs={0}> <AvatarFallback delayMs={0}>
<AvatarIdenticon src={defaultAvatar} /> <AvatarIdenticon src={defaultAvatar} />
@ -156,7 +182,10 @@ function TitlebarAccountMenu({
const { account, profile } = useNostr() const { account, profile } = useNostr()
const pubkey = account?.pubkey const pubkey = account?.pubkey
const { profile: fetchedProfile } = useFetchProfile(pubkey) const { profile: fetchedProfile } = useFetchProfile(pubkey)
const resolvedProfile = fetchedProfile ?? profile const resolvedProfile = useMemo(
() => profileForActivePubkey(pubkey, profile, fetchedProfile),
[pubkey, profile, fetchedProfile]
)
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const defaultAvatar = useMemo( const defaultAvatar = useMemo(
@ -181,7 +210,7 @@ function TitlebarAccountMenu({
<video src={resolvedProfile.avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline /> <video src={resolvedProfile.avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
</div> </div>
) : ( ) : (
<Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')}> <Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')} key={pubkey}>
<AvatarImage <AvatarImage
src={resolvedProfile.avatar || defaultAvatar} src={resolvedProfile.avatar || defaultAvatar}
className="object-cover object-center" className="object-cover object-center"

12
src/components/MuteButton/index.tsx

@ -9,6 +9,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { BellOff } from 'lucide-react' import { BellOff } from 'lucide-react'
@ -20,6 +21,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey: accountPubkey, checkLogin } = useNostr() const { pubkey: accountPubkey, checkLogin } = useNostr()
const { signControlProps } = useSignGatedControl()
const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
useMuteList() useMuteList()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
@ -69,7 +71,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
className="rounded-full min-w-20 max-w-full text-destructive whitespace-normal break-words px-3" className="rounded-full min-w-20 max-w-full text-destructive whitespace-normal break-words px-3"
variant="secondary" variant="secondary"
onClick={handleUnmute} onClick={handleUnmute}
disabled={updating || changing} {...signControlProps({ disabled: updating || changing })}
> >
{updating ? ( {updating ? (
<Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden />
@ -84,7 +86,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
<Button <Button
variant="destructive" variant="destructive"
className="w-20 min-w-20 rounded-full" className="w-20 min-w-20 rounded-full"
disabled={updating || changing} {...signControlProps({ disabled: updating || changing })}
> >
{updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute')} {updating ? <Skeleton className="mx-auto size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute')}
</Button> </Button>
@ -100,7 +102,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive" className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost" variant="ghost"
onClick={(e) => handleMute(e, true)} onClick={(e) => handleMute(e, true)}
disabled={updating || changing} {...signControlProps({ disabled: updating || changing })}
> >
{updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user privately')} {updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user privately')}
</Button> </Button>
@ -108,7 +110,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive" className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
variant="ghost" variant="ghost"
onClick={(e) => handleMute(e, false)} onClick={(e) => handleMute(e, false)}
disabled={updating || changing} {...signControlProps({ disabled: updating || changing })}
> >
{updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user publicly')} {updating ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : t('Mute user publicly')}
</Button> </Button>
@ -124,6 +126,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => handleMute(e, true)} onClick={(e) => handleMute(e, true)}
disabled={signControlProps().disabled}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<BellOff /> <BellOff />
@ -131,6 +134,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => handleMute(e, false)} onClick={(e) => handleMute(e, false)}
disabled={signControlProps().disabled}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<BellOff /> <BellOff />

51
src/components/Nip07ExtensionKeyMismatchToast/index.tsx

@ -1,51 +0,0 @@
import { Button } from '@/components/ui/button'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export function Nip07ExtensionKeyMismatchToast({
toastId,
onReload,
onUseExtensionIdentity
}: {
toastId: string | number
onReload: () => void
onUseExtensionIdentity: () => void
}) {
const { t } = useTranslation()
return (
<div
role="alert"
className="relative w-[min(22rem,calc(100vw-2rem))] max-w-[420px] rounded-lg border border-destructive/50 bg-background p-4 pr-10 text-foreground shadow-lg"
>
<button
type="button"
className="absolute right-2 top-2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t('Close')}
onClick={() => toast.dismiss(toastId)}
>
<X className="size-4" aria-hidden />
</button>
<p className="text-sm font-semibold text-destructive">
{t('nip07.extensionKeyMismatchTitle', {
defaultValue: 'Extension key mismatch'
})}
</p>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{t('nip07.extensionKeyMismatchBody', {
defaultValue:
'Your browser extension is using a different key than this tab. Switch keys in the extension, reload the page, or sign in with the extension’s current key.'
})}
</p>
<div className="mt-3 flex flex-col gap-2">
<Button type="button" size="sm" variant="secondary" className="w-full justify-center" onClick={onReload}>
{t('nip07.reloadPage')}
</Button>
<Button type="button" size="sm" className="w-full justify-center" onClick={onUseExtensionIdentity}>
{t('nip07.useExtensionIdentity')}
</Button>
</div>
</div>
)
}

12
src/components/NoteStats/LikeButton.tsx

@ -20,6 +20,7 @@ import {
isDiscussionVoteEmoji isDiscussionVoteEmoji
} from '@/lib/discussion-votes' } from '@/lib/discussion-votes'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
@ -63,6 +64,7 @@ export function LikeButtonWithStats({
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { canSignEvents, signControlProps } = useSignGatedControl()
const { relays: statsRelays } = useNoteStatsRelayHints() const { relays: statsRelays } = useNoteStatsRelayHints()
const [liking, setLiking] = useState(false) const [liking, setLiking] = useState(false)
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false) const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
@ -234,6 +236,7 @@ export function LikeButtonWithStats({
} }
const openReactionPicker = () => { const openReactionPicker = () => {
if (!canSignEvents) return
if (myLastEmoji && !isEmojiReactionsOpen) { if (myLastEmoji && !isEmojiReactionsOpen) {
like(myLastEmoji) like(myLastEmoji)
return return
@ -245,8 +248,7 @@ export function LikeButtonWithStats({
<button <button
type="button" type="button"
className="flex h-full min-w-0 items-center gap-1.5 px-2 text-muted-foreground enabled:hover:text-primary touch-manipulation" className="flex h-full min-w-0 items-center gap-1.5 px-2 text-muted-foreground enabled:hover:text-primary touch-manipulation"
title={t('Like')} {...signControlProps({ title: t('Like'), disabled: liking })}
disabled={liking}
onClick={openReactionPicker} onClick={openReactionPicker}
> >
{liking ? ( {liking ? (
@ -289,8 +291,10 @@ export function LikeButtonWithStats({
<button <button
type="button" type="button"
className="flex h-full shrink-0 items-center px-2 sm:px-2.5 enabled:hover:text-primary touch-manipulation" className="flex h-full shrink-0 items-center px-2 sm:px-2.5 enabled:hover:text-primary touch-manipulation"
title={emoji === '+' ? t('Upvote') : t('Downvote')} {...signControlProps({
disabled={liking} title: emoji === '+' ? t('Upvote') : t('Downvote'),
disabled: liking
})}
onClick={() => { onClick={() => {
like(emoji) like(emoji)
}} }}

5
src/components/NoteStats/ReplyButton.tsx

@ -1,5 +1,6 @@
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import type { TNoteStats } from '@/services/note-stats.service' import type { TNoteStats } from '@/services/note-stats.service'
import { MessageCircle } from 'lucide-react' import { MessageCircle } from 'lucide-react'
@ -18,6 +19,7 @@ type ReplyButtonProps = {
export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: ReplyButtonProps) { export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: ReplyButtonProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { signControlProps } = useSignGatedControl()
const { replyCount, hasReplied } = useMemo(() => { const { replyCount, hasReplied } = useMemo(() => {
const hasReplied = pubkey const hasReplied = pubkey
? noteStats?.replies?.some((reply) => reply.pubkey === pubkey) ? noteStats?.replies?.some((reply) => reply.pubkey === pubkey)
@ -39,6 +41,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
return ( return (
<> <>
<button <button
type="button"
className={cn( className={cn(
'flex gap-1.5 items-center enabled:hover:text-blue-400 px-2 h-full min-h-11 touch-manipulation', 'flex gap-1.5 items-center enabled:hover:text-blue-400 px-2 h-full min-h-11 touch-manipulation',
hasReplied ? 'text-blue-400' : 'text-muted-foreground' hasReplied ? 'text-blue-400' : 'text-muted-foreground'
@ -49,7 +52,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
setOpen(true) setOpen(true)
}) })
}} }}
title={t('Reply')} {...signControlProps({ title: t('Reply') })}
> >
<MessageCircle /> <MessageCircle />
{!hideCount && replyCountLabel !== '' && ( {!hideCount && replyCountLabel !== '' && (

9
src/components/NoteStats/RepostButton.tsx

@ -18,6 +18,7 @@ import { createRepostDraftEvent } from '@/lib/draft-event'
import { getNoteBech32Id } from '@/lib/event' import { getNoteBech32Id } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
@ -42,6 +43,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { publish, checkLogin, pubkey } = useNostr() const { publish, checkLogin, pubkey } = useNostr()
const { canSignEvents, signControlProps } = useSignGatedControl()
const { relays: statsRelays } = useNoteStatsRelayHints() const { relays: statsRelays } = useNoteStatsRelayHints()
const [reposting, setReposting] = useState(false) const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false) const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
@ -54,7 +56,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
} }
}, [noteStats, event.id, pubkey]) }, [noteStats, event.id, pubkey])
const showRepostCount = !hideCount && (statsLoaded || (repostCount ?? 0) > 0) const showRepostCount = !hideCount && (statsLoaded || (repostCount ?? 0) > 0)
const canRepost = !hasReposted && !reposting const canRepost = canSignEvents && !hasReposted && !reposting
const repost = async () => { const repost = async () => {
checkLogin(async () => { checkLogin(async () => {
@ -111,8 +113,9 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
'flex h-full items-center enabled:hover:text-lime-500 px-2 touch-manipulation', 'flex h-full items-center enabled:hover:text-lime-500 px-2 touch-manipulation',
hasReposted ? 'text-lime-500' : 'text-muted-foreground' hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)} )}
title={t('Boost')} {...signControlProps({ title: t('Boost'), disabled: !canSignEvents })}
onClick={() => { onClick={() => {
if (!canSignEvents) return
if (isSmallScreen) { if (isSmallScreen) {
setIsDrawerOpen(true) setIsDrawerOpen(true)
} }
@ -173,6 +176,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
setIsPostDialogOpen(true) setIsPostDialogOpen(true)
}) })
}} }}
{...signControlProps()}
className={drawerMenuButtonClassName} className={drawerMenuButtonClassName}
variant="ghost" variant="ghost"
> >
@ -209,6 +213,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
setIsPostDialogOpen(true) setIsPostDialogOpen(true)
}) })
}} }}
disabled={!canSignEvents}
> >
<PencilLine /> {t('Quote')} <PencilLine /> {t('Quote')}
</DropdownMenuItem> </DropdownMenuItem>

4
src/components/PostEditor/PostContent.tsx

@ -198,7 +198,7 @@ export default function PostContent({
discussionDynamicTopics?: TDiscussionDynamicTopics | null discussionDynamicTopics?: TDiscussionDynamicTopics | null
}) { }) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin, canSignEvents } = useNostr()
const { addReplies } = useReply() const { addReplies } = useReply()
const mergePublishedReplyIntoThread = useCallback( const mergePublishedReplyIntoThread = useCallback(
@ -643,6 +643,7 @@ export default function PostContent({
: (mediaNoteKind !== null && mediaUrl) || hasNonemptyContent : (mediaNoteKind !== null && mediaUrl) || hasNonemptyContent
return ( return (
!!pubkey && !!pubkey &&
canSignEvents &&
!posting && !posting &&
!uploadProgresses.length && !uploadProgresses.length &&
discussionOk && discussionOk &&
@ -658,6 +659,7 @@ export default function PostContent({
) )
}, [ }, [
pubkey, pubkey,
canSignEvents,
text, text,
getDeterminedKind, getDeterminedKind,
posting, posting,

86
src/components/StoredAccountSwitchSelect.tsx

@ -1,12 +1,12 @@
import { SimpleUserAvatar } from '@/components/UserAvatar' import { SimpleUserAvatar } from '@/components/UserAvatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { accountPointerKey, isRedundantAccountPick, listSwitchableAccounts } from '@/lib/account' import { accountPointerKey, isRedundantAccountPick, listSwitchableAccounts } from '@/lib/account'
import { formatPubkey, hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { accountPubkeyToHex, formatPubkey, hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Nip07Signer } from '@/providers/NostrProvider/nip-07.signer' import { Nip07Signer } from '@/providers/NostrProvider/nip-07.signer'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import type { TAccountPointer } from '@/types' import type { TAccountPointer } from '@/types'
import { Loader2 } from 'lucide-react' import { Loader2, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -24,6 +24,17 @@ type Props = {
alignEnd?: boolean alignEnd?: boolean
} }
const EXTENSION_SYNC_HINT_DISMISSED_PREFIX = 'extensionSyncHintDismissed:'
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). * 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. * Avatar chips instead of a native select; NIP-07 extension sync hint + retry when read-only.
@ -41,7 +52,6 @@ export default function StoredAccountSwitchSelect({
account, account,
accounts, accounts,
switchAccount, switchAccount,
viewAccountAsReadOnly,
isAccountSessionHydrating, isAccountSessionHydrating,
retryNip07SignerForPreferredAccount, retryNip07SignerForPreferredAccount,
adoptExtensionNip07Identity adoptExtensionNip07Identity
@ -50,6 +60,7 @@ export default function StoredAccountSwitchSelect({
const [switchingKey, setSwitchingKey] = useState<string | null>(null) const [switchingKey, setSwitchingKey] = useState<string | null>(null)
const [retryingExtension, setRetryingExtension] = useState(false) const [retryingExtension, setRetryingExtension] = useState(false)
const [extensionPubkey, setExtensionPubkey] = useState<string | null>(null) const [extensionPubkey, setExtensionPubkey] = useState<string | null>(null)
const [extensionSyncHintDismissed, setExtensionSyncHintDismissed] = useState(false)
const sessionPubkey = useMemo(() => { const sessionPubkey = useMemo(() => {
const cur = pubkey?.trim() const cur = pubkey?.trim()
@ -72,6 +83,10 @@ export default function StoredAccountSwitchSelect({
return !hexPubkeysEqual(normalizeHexPubkey(extensionPubkey), sessionPubkey) return !hexPubkeysEqual(normalizeHexPubkey(extensionPubkey), sessionPubkey)
}, [extensionPubkey, sessionPubkey]) }, [extensionPubkey, sessionPubkey])
useEffect(() => {
setExtensionSyncHintDismissed(readExtensionSyncHintDismissed(sessionPubkey))
}, [sessionPubkey])
useEffect(() => { useEffect(() => {
if (!needsExtensionSync) { if (!needsExtensionSync) {
setExtensionPubkey(null) setExtensionPubkey(null)
@ -93,13 +108,36 @@ export default function StoredAccountSwitchSelect({
} }
}, [needsExtensionSync]) }, [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( const handlePick = useCallback(
async (nextAccount: TAccountPointer) => { async (nextAccount: TAccountPointer) => {
const target = normalizeHexPubkey(nextAccount.pubkey) const target = accountPubkeyToHex(nextAccount.pubkey)
if (isRedundantAccountPick(nextAccount, account)) { if (isRedundantAccountPick(nextAccount, account)) {
if (account?.signerType === 'npub' && nextAccount.signerType === 'nip-07') { if (account?.signerType === 'npub' && nextAccount.signerType === 'nip-07') {
setSwitchingKey(accountPointerKey(nextAccount)) setSwitchingKey(accountPointerKey(nextAccount))
try { try {
const switched = await switchAccount(nextAccount)
if (switched) return
const ok = await retryNip07SignerForPreferredAccount() const ok = await retryNip07SignerForPreferredAccount()
if (ok) toast.success(t('accountSwitch.extensionConnected')) if (ok) toast.success(t('accountSwitch.extensionConnected'))
else toast.error(t('accountSwitch.extensionRetryFailed')) else toast.error(t('accountSwitch.extensionRetryFailed'))
@ -111,18 +149,12 @@ export default function StoredAccountSwitchSelect({
} }
setSwitchingKey(accountPointerKey(nextAccount)) setSwitchingKey(accountPointerKey(nextAccount))
try { try {
const needsWriteSigner = const switched = await switchAccount(nextAccount)
nextAccount.signerType === 'nsec' ||
nextAccount.signerType === 'ncryptsec' ||
nextAccount.signerType === 'bunker'
const switched = needsWriteSigner
? await switchAccount(nextAccount)
: await viewAccountAsReadOnly(nextAccount)
if (!switched) { if (!switched) {
toast.error(t('notificationsSwitchAccountFailed')) toast.error(t('notificationsSwitchAccountFailed'))
return return
} }
if (!hexPubkeysEqual(normalizeHexPubkey(switched), target)) { if (target && !hexPubkeysEqual(switched, target)) {
toast.error(t('notificationsSwitchAccountFailed')) toast.error(t('notificationsSwitchAccountFailed'))
return return
} }
@ -130,13 +162,7 @@ export default function StoredAccountSwitchSelect({
setSwitchingKey(null) setSwitchingKey(null)
} }
}, },
[ [account, switchAccount, retryNip07SignerForPreferredAccount, t]
account,
switchAccount,
viewAccountAsReadOnly,
retryNip07SignerForPreferredAccount,
t
]
) )
const handleRetryExtension = useCallback(async () => { const handleRetryExtension = useCallback(async () => {
@ -153,6 +179,16 @@ export default function StoredAccountSwitchSelect({
} }
}, [retryNip07SignerForPreferredAccount, t]) }, [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])
if (storedAccounts.length <= 1 || !sessionPubkey) return null if (storedAccounts.length <= 1 || !sessionPubkey) return null
const busy = isAccountSessionHydrating || switchingKey !== null const busy = isAccountSessionHydrating || switchingKey !== null
@ -235,13 +271,21 @@ export default function StoredAccountSwitchSelect({
</div> </div>
</div> </div>
{needsExtensionSync ? ( {needsExtensionSync && !extensionSyncHintDismissed ? (
<div <div
className={cn( 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', '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' 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> <p className="leading-relaxed">{t('accountSwitch.extensionSyncHint')}</p>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
<Button <Button

1
src/hooks/index.tsx

@ -1,3 +1,4 @@
export * from './useSignGatedControl'
export * from './useRelayPageFeedPolicy' export * from './useRelayPageFeedPolicy'
export * from './useNearViewport' export * from './useNearViewport'
export * from './useFetchCalendarRsvps' export * from './useFetchCalendarRsvps'

6
src/hooks/useFetchProfile.tsx

@ -6,7 +6,7 @@ import {
isPubkeyAwaitingProfileBatch, isPubkeyAwaitingProfileBatch,
shouldDeferPerPubkeyProfileNetwork shouldDeferPerPubkeyProfileNetwork
} from '@/lib/profile-batch-coordinator' } from '@/lib/profile-batch-coordinator'
import { normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey'
import { useNostrOptional } from '@/providers/nostr-context' import { useNostrOptional } from '@/providers/nostr-context'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { eventService, replaceableEventService } from '@/services/client.service' import { eventService, replaceableEventService } from '@/services/client.service'
@ -578,6 +578,10 @@ export function useFetchProfile(id?: string, skipCache = false) {
processingPubkeyRef.current = null processingPubkeyRef.current = null
} }
if (extractedPubkey && profile && !hexPubkeysEqual(profile.pubkey, extractedPubkey)) {
setProfile(null)
}
const cancelled = { current: false } const cancelled = { current: false }
if (!extractedPubkey) { if (!extractedPubkey) {

20
src/hooks/useSignGatedControl.ts

@ -0,0 +1,20 @@
import { useNostr } from '@/providers/NostrProvider'
import { useTranslation } from 'react-i18next'
/**
* Read-only (npub) session helpers for disabling publish / social controls.
*/
export function useSignGatedControl() {
const { canSignEvents } = useNostr()
const { t } = useTranslation()
const readOnlyTitle = t('readOnlySession.hint')
return {
canSignEvents,
/** Merge into button/menu props: disabled + title when read-only. */
signControlProps: (extra?: { disabled?: boolean; title?: string }) => ({
disabled: !canSignEvents || Boolean(extra?.disabled),
title: !canSignEvents ? readOnlyTitle : extra?.title
})
}
}

2
src/i18n/locales/de.ts

@ -1002,6 +1002,8 @@ export default {
'accountSwitch.extensionConnected': 'Erweiterung für dieses Konto verbunden.', 'accountSwitch.extensionConnected': 'Erweiterung für dieses Konto verbunden.',
'accountSwitch.extensionRetryFailed': 'accountSwitch.extensionRetryFailed':
'Erweiterungsschlüssel passt noch nicht. Schlüssel in der Erweiterung wechseln und erneut versuchen.', 'Erweiterungsschlüssel passt noch nicht. Schlüssel in der Erweiterung wechseln und erneut versuchen.',
'accountSwitch.extensionUnavailable':
'Browser-Erweiterung nicht erreichbar. nos2x/Alby entsperren, Seite erlauben, dann Konto erneut wählen.',
'Show untrusted {type}': 'Untrusted {{type}} anzeigen', 'Show untrusted {type}': 'Untrusted {{type}} anzeigen',
'Hide untrusted {type}': 'Untrusted {{type}} ausblenden', 'Hide untrusted {type}': 'Untrusted {{type}} ausblenden',
'Currently hiding {type} from untrusted users.': 'Currently hiding {type} from untrusted users.':

2
src/i18n/locales/en.ts

@ -1019,6 +1019,8 @@ export default {
'accountSwitch.extensionConnected': 'Extension connected for this account.', 'accountSwitch.extensionConnected': 'Extension connected for this account.',
'accountSwitch.extensionRetryFailed': 'accountSwitch.extensionRetryFailed':
'Extension key still does not match. Switch the key in your extension, then try again.', 'Extension key still does not match. Switch the key in your extension, then try again.',
'accountSwitch.extensionUnavailable':
'Could not reach the browser extension. Unlock nos2x/Alby, allow this site, then click the account again.',
'Show untrusted {type}': 'Show untrusted {{type}}', 'Show untrusted {type}': 'Show untrusted {{type}}',
'Hide untrusted {type}': 'Hide untrusted {{type}}', 'Hide untrusted {type}': 'Hide untrusted {{type}}',
'Currently hiding {type} from untrusted users.': 'Currently hiding {type} from untrusted users.':

24
src/lib/account.test.ts

@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isRedundantAccountPick, isSameAccount, listSwitchableAccounts } from './account' import {
findStoredAccountForPointer,
isRedundantAccountPick,
isSameAccount,
listSwitchableAccounts
} from './account'
import { nip19 } from 'nostr-tools'
import { getPublicKey, generateSecretKey } from 'nostr-tools'
describe('listSwitchableAccounts', () => { describe('listSwitchableAccounts', () => {
const A = 'a'.repeat(64) const A = 'a'.repeat(64)
@ -39,6 +46,17 @@ describe('isSameAccount', () => {
}) })
}) })
describe('findStoredAccountForPointer', () => {
it('finds nip-07 row when pointer pubkey is npub bech32', () => {
const sk = generateSecretKey()
const hex = getPublicKey(sk)
const npub = nip19.npubEncode(hex)
const accounts = [{ pubkey: hex, signerType: 'nip-07' as const }]
const found = findStoredAccountForPointer(accounts, { pubkey: npub, signerType: 'nip-07' })
expect(found?.pubkey).toBe(hex)
})
})
describe('isRedundantAccountPick', () => { describe('isRedundantAccountPick', () => {
const A = 'a'.repeat(64) const A = 'a'.repeat(64)
@ -47,12 +65,12 @@ describe('isRedundantAccountPick', () => {
expect(isRedundantAccountPick(row, row)).toBe(true) expect(isRedundantAccountPick(row, row)).toBe(true)
}) })
it('allows nip-07 pick while session is read-only npub for same pubkey', () => { it('treats nip-07 pick as redundant when session is read-only npub for same pubkey (reconnect)', () => {
expect( expect(
isRedundantAccountPick( isRedundantAccountPick(
{ pubkey: A, signerType: 'nip-07' }, { pubkey: A, signerType: 'nip-07' },
{ pubkey: A, signerType: 'npub' } { pubkey: A, signerType: 'npub' }
) )
).toBe(false) ).toBe(true)
}) })
}) })

35
src/lib/account.ts

@ -1,16 +1,16 @@
import { hexPubkeysEqual, isValidPubkey, normalizeHexPubkey } from '@/lib/pubkey' import { accountPubkeyToHex, hexPubkeysEqual } from '@/lib/pubkey'
import { TAccountPointer, TSignerType } from '@/types' import { TAccount, TAccountPointer, TSignerType } from '@/types'
export function isSameAccount(a: TAccountPointer | null, b: TAccountPointer | null) { export function isSameAccount(a: TAccountPointer | null, b: TAccountPointer | null) {
if (!a || !b) return false if (!a || !b) return false
if (a.signerType !== b.signerType) return false if (a.signerType !== b.signerType) return false
return hexPubkeysEqual(normalizeHexPubkey(a.pubkey), normalizeHexPubkey(b.pubkey)) return hexPubkeysEqual(a.pubkey, b.pubkey)
} }
/** Same hex pubkey, regardless of signer type (e.g. npub vs nip-07 rows). */ /** Same hex pubkey, regardless of signer type (e.g. npub vs nip-07 rows). */
export function isSameAccountPubkey(a: TAccountPointer | null, b: TAccountPointer | null) { export function isSameAccountPubkey(a: TAccountPointer | null, b: TAccountPointer | null) {
if (!a || !b) return false if (!a || !b) return false
return hexPubkeysEqual(normalizeHexPubkey(a.pubkey), normalizeHexPubkey(b.pubkey)) return hexPubkeysEqual(a.pubkey, b.pubkey)
} }
/** False when the user should be allowed to pick this row (e.g. reconnect nip-07 while read-only). */ /** False when the user should be allowed to pick this row (e.g. reconnect nip-07 while read-only). */
@ -24,6 +24,14 @@ export function isRedundantAccountPick(target: TAccountPointer, session: TAccoun
) { ) {
return true return true
} }
/** Read-only npub session for a pubkey whose chip is nip-07 — allow reconnect on re-pick. */
if (
session.signerType === 'npub' &&
target.signerType === 'nip-07' &&
isSameAccountPubkey(target, session)
) {
return true
}
return false return false
} }
@ -39,8 +47,7 @@ const SWITCH_SIGNER_PRIORITY: Record<TSignerType, number> = {
function normalizedPubkeyHex(account: TAccountPointer): string | null { function normalizedPubkeyHex(account: TAccountPointer): string | null {
const raw = account.pubkey?.trim() const raw = account.pubkey?.trim()
if (!raw) return null if (!raw) return null
const pk = normalizeHexPubkey(raw) return accountPubkeyToHex(raw)
return isValidPubkey(pk) ? pk : null
} }
/** /**
@ -71,3 +78,19 @@ export function accountPointerKey(account: TAccountPointer): string {
const pk = normalizedPubkeyHex(account) const pk = normalizedPubkeyHex(account)
return pk ? `${pk}:${account.signerType}` : account.signerType return pk ? `${pk}:${account.signerType}` : account.signerType
} }
/** Resolve a stored row for a switcher/login pointer (hex/npub pubkey, any signer type). */
export function findStoredAccountForPointer(
accounts: readonly TAccount[],
act: TAccountPointer
): TAccount | undefined {
const direct = accounts.find((a) => isSameAccount(a, act))
if (direct) return direct
const pk = normalizedPubkeyHex(act)
if (!pk) return undefined
const nip07 = accounts.find(
(a) => a.signerType === 'nip-07' && hexPubkeysEqual(a.pubkey, pk)
)
if (nip07) return nip07
return accounts.find((a) => hexPubkeysEqual(a.pubkey, pk))
}

28
src/lib/nip07-extension-key-mismatch-toast.tsx

@ -1,28 +0,0 @@
import { Nip07ExtensionKeyMismatchToast } from '@/components/Nip07ExtensionKeyMismatchToast'
import { toast } from 'sonner'
/** Stacked layout with dismiss — avoids Sonner squeezing long text beside action buttons. */
export function showNip07ExtensionKeyMismatchToast(opts: {
onReload: () => void
onUseExtensionIdentity: () => void
}): void {
toast.custom(
(id) => (
<Nip07ExtensionKeyMismatchToast
toastId={id}
onReload={() => {
toast.dismiss(id)
opts.onReload()
}}
onUseExtensionIdentity={() => {
toast.dismiss(id)
void opts.onUseExtensionIdentity()
}}
/>
),
{
duration: 35_000,
unstyled: true
}
)
}

12
src/lib/pubkey-nip07.test.ts

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { getPublicKey, generateSecretKey, nip19 } from 'nostr-tools' import { getPublicKey, generateSecretKey, nip19 } from 'nostr-tools'
import { pubkeyFromNip07Extension } from './pubkey' import { hexPubkeysEqual, pubkeyFromNip07Extension } from './pubkey'
describe('pubkeyFromNip07Extension', () => { describe('pubkeyFromNip07Extension', () => {
it('accepts hex pubkey', () => { it('accepts hex pubkey', () => {
@ -21,3 +21,13 @@ describe('pubkeyFromNip07Extension', () => {
expect(pubkeyFromNip07Extension('')).toBeNull() expect(pubkeyFromNip07Extension('')).toBeNull()
}) })
}) })
describe('hexPubkeysEqual', () => {
it('matches hex and npub for the same key', () => {
const sk = generateSecretKey()
const hex = getPublicKey(sk)
const npub = nip19.npubEncode(hex)
expect(hexPubkeysEqual(hex, npub)).toBe(true)
expect(hexPubkeysEqual(npub, hex.toUpperCase())).toBe(true)
})
})

17
src/lib/pubkey.ts

@ -81,16 +81,17 @@ export function normalizeHexPubkey(pubkey: string): string {
return /^[0-9a-f]{64}$/i.test(t) ? t.toLowerCase() : t return /^[0-9a-f]{64}$/i.test(t) ? t.toLowerCase() : t
} }
/** Hex pubkey from stored account fields (hex, npub, or `nostr:` URI). */
export function accountPubkeyToHex(pubkey: string): string | null {
const pk = userIdToPubkey(pubkey.trim())
return isValidPubkey(pk) ? pk.toLowerCase() : null
}
export function hexPubkeysEqual(a: string, b: string): boolean { export function hexPubkeysEqual(a: string, b: string): boolean {
if (a === b) return true if (a === b) return true
const na = normalizeHexPubkey(a) const ha = accountPubkeyToHex(a)
const nb = normalizeHexPubkey(b) const hb = accountPubkeyToHex(b)
return ( return ha !== null && hb !== null && ha === hb
na.length === 64 &&
nb.length === 64 &&
/^[0-9a-f]{64}$/.test(na) &&
na === nb
)
} }
export function isValidPubkey(pubkey: string) { export function isValidPubkey(pubkey: string) {

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

@ -1,6 +1,5 @@
import NoteList, { type TNoteListRef } from '@/components/NoteList' import NoteList, { type TNoteListRef } from '@/components/NoteList'
import { NoteCardLoadingSkeleton } from '@/components/NoteCard' import { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import StoredAccountSwitchSelect from '@/components/StoredAccountSwitchSelect'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -1060,14 +1059,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
) : selectedFauxSpell && fauxSubRequests.length === 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div> <div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
) : selectedFauxSpell && fauxSubRequests.length > 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length > 0 ? (
<>
{selectedFauxSpell === 'notifications' ? (
<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="w-full" showLabelAlways />
) : null}
</div>
) : null}
<div className="min-h-0 min-w-0 flex-1"> <div className="min-h-0 min-w-0 flex-1">
<NoteList <NoteList
ref={spellFeedListRef} ref={spellFeedListRef}
@ -1127,7 +1118,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
} }
/> />
</div> </div>
</>
) : selectedSpell ? ( ) : selectedSpell ? (
subRequests.length > 0 ? ( subRequests.length > 0 ? (
<NoteList <NoteList

298
src/providers/NostrProvider/index.tsx

@ -47,7 +47,15 @@ import {
} from '@/lib/viewer-blocked-relays' } from '@/lib/viewer-blocked-relays'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { formatPubkey, hexPubkeysEqual, isValidPubkey, normalizeHexPubkey, pubkeyToNpub, pubkeyFromNip07Extension } from '@/lib/pubkey' import {
accountPubkeyToHex,
formatPubkey,
hexPubkeysEqual,
isValidPubkey,
normalizeHexPubkey,
pubkeyToNpub,
pubkeyFromNip07Extension
} from '@/lib/pubkey'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import client from '@/services/client.service' import client from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service' import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
@ -75,9 +83,8 @@ import { NostrContext, type TNostrContext } from '@/providers/nostr-context'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEventCallback } from '@/hooks/use-event-callback' import { useEventCallback } from '@/hooks/use-event-callback'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { isSameAccount } from '@/lib/account' import { findStoredAccountForPointer, isSameAccount } from '@/lib/account'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { showNip07ExtensionKeyMismatchToast } from '@/lib/nip07-extension-key-mismatch-toast'
import { toast } from 'sonner' import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer' import { BunkerSigner } from './bunker.signer'
import { Nip07Signer } from './nip-07.signer' import { Nip07Signer } from './nip-07.signer'
@ -146,7 +153,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [ncryptsecPasswordOpen, setNcryptsecPasswordOpen] = useState(false) const [ncryptsecPasswordOpen, setNcryptsecPasswordOpen] = useState(false)
const ncryptsecPasswordResolveRef = useRef<((value: string | null) => void) | null>(null) const ncryptsecPasswordResolveRef = useRef<((value: string | null) => void) | null>(null)
/** One toast per mismatch episode; cleared after a successful NIP-07 login. */ /** One toast per mismatch episode; cleared after a successful NIP-07 login. */
const nip07KeyMismatchToastShownRef = useRef(false)
/** /**
* User picked a stored NIP-07 account from the notifications switcher but the extension key * User picked a stored NIP-07 account from the notifications switcher but the extension key
* differs we fall back to read-only npub without spamming the mismatch toast / recovery UI. * differs we fall back to read-only npub without spamming the mismatch toast / recovery UI.
@ -1172,7 +1178,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const normalized = normalizeLoginAccount(act) const normalized = normalizeLoginAccount(act)
const prev = accountForReplaceablesSyncRef.current const prev = accountForReplaceablesSyncRef.current
if (normalized.signerType === 'nip-07') { if (normalized.signerType === 'nip-07') {
nip07KeyMismatchToastShownRef.current = false
intentionalNip07ReadOnlyPubkeyRef.current = null intentionalNip07ReadOnlyPubkeyRef.current = null
} }
storage.addAccount(normalized) storage.addAccount(normalized)
@ -1252,7 +1257,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// If loginWithAccountPointer fell back to read-only npub it skips storage.switchAccount. // If loginWithAccountPointer fell back to read-only npub it skips storage.switchAccount.
// Persist the user's intent here so session restore and NIP-07 recovery target this row. // Persist the user's intent here so session restore and NIP-07 recovery target this row.
if (result !== null) { if (result !== null) {
const storedFull = storage.findAccount(act) const storedFull = findStoredAccountForPointer(storage.getAccounts(), act)
if (storedFull && !isSameAccount(storage.getCurrentAccount(), storedFull)) { if (storedFull && !isSameAccount(storage.getCurrentAccount(), storedFull)) {
storage.switchAccount(storedFull) storage.switchAccount(storedFull)
syncAccountPointersFromStorage() syncAccountPointersFromStorage()
@ -1262,7 +1267,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return result return result
} }
/** Browse as an identity without requiring the browser extension to match (no NIP-07 recovery loop). */ /** Browse read-only, or connect NIP-07 when the extension already matches this pubkey. */
const viewAccountAsReadOnly = async (act: TAccountPointer): Promise<string | null> => { const viewAccountAsReadOnly = async (act: TAccountPointer): Promise<string | null> => {
const stored = storage.findAccount(act) const stored = storage.findAccount(act)
const normalized = normalizeLoginAccount( const normalized = normalizeLoginAccount(
@ -1270,8 +1275,30 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
) )
if (!isValidPubkey(normalized.pubkey)) return null if (!isValidPubkey(normalized.pubkey)) return null
const nip07Row =
stored?.signerType === 'nip-07'
? stored
: storage
.getAccounts()
.find(
(a) =>
a.signerType === 'nip-07' &&
hexPubkeysEqual(normalizeHexPubkey(a.pubkey), normalized.pubkey)
)
if (nip07Row) {
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
const extPubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey())
if (extPubkey && hexPubkeysEqual(extPubkey, normalized.pubkey)) {
return login(nip07Signer, nip07Row)
}
} catch {
// Fall through to intentional read-only browse.
}
}
intentionalNip07ReadOnlyPubkeyRef.current = normalized.pubkey.toLowerCase() intentionalNip07ReadOnlyPubkeyRef.current = normalized.pubkey.toLowerCase()
nip07KeyMismatchToastShownRef.current = true
const storageRow: TAccount = const storageRow: TAccount =
stored ?? stored ??
@ -1401,18 +1428,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
try { try {
const nip07Signer = new Nip07Signer() const nip07Signer = new Nip07Signer()
await nip07Signer.init() await nip07Signer.init()
const raw = await nip07Signer.getPublicKey() const pubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey())
const pubkey = pubkeyFromNip07Extension(raw)
if (!pubkey) { if (!pubkey) {
throw new Error( throw new Error('Extension returned an invalid pubkey')
raw
? 'Extension returned an invalid pubkey'
: 'You did not allow to access your pubkey'
)
} }
const readOnlyDup = storage const readOnlyDup = storage
.getAccounts() .getAccounts()
.find((a) => a.pubkey === pubkey && a.signerType === 'npub') .find((a) => a.signerType === 'npub' && hexPubkeysEqual(a.pubkey, pubkey))
if (readOnlyDup) { if (readOnlyDup) {
storage.removeAccount(readOnlyDup) storage.removeAccount(readOnlyDup)
syncAccountPointersFromStorage() syncAccountPointersFromStorage()
@ -1464,24 +1486,57 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
options?: { userInitiatedSwitch?: boolean } options?: { userInitiatedSwitch?: boolean }
): Promise<string | null> => { ): Promise<string | null> => {
const fallbackToReadOnlyNpub = (pubkey: string, reason?: unknown): string => { const fallbackToReadOnlyNpub = (pubkey: string, reason?: unknown): string => {
const pk =
accountPubkeyToHex(pubkey) ??
(isValidPubkey(normalizeHexPubkey(pubkey)) ? normalizeHexPubkey(pubkey) : null)
if (!pk) return pubkey
const apply = (): string => {
const npubSigner = new NpubSigner() const npubSigner = new NpubSigner()
const npub = nip19.npubEncode(pubkey) npubSigner.login(nip19.npubEncode(pk))
npubSigner.login(npub) const prev = accountForReplaceablesSyncRef.current
// Keep this fallback in-memory only; do not rewrite stored account type. const sessionChanged =
setAccount({ pubkey, signerType: 'npub' }) !prev || !hexPubkeysEqual(prev.pubkey, pk) || prev.signerType !== 'npub'
if (sessionChanged) {
clearSessionUiForAccountChange()
accountHydrationGenerationRef.current += 1
lastNetworkHydrateAccountPubkeyRef.current = null
}
const pointer = { pubkey: pk, signerType: 'npub' as const }
setAccount(pointer)
setSigner(npubSigner) setSigner(npubSigner)
accountForReplaceablesSyncRef.current = pointer
client.setSigner(npubSigner, 'npub')
client.pubkey = pk
void client.syncViewerPersonalRelayKeys(pk)
if (sessionChanged) {
setAccountNetworkHydrateBump((n) => n + 1)
}
logger.warn('[NostrProvider] Signer unavailable during restore; using read-only session', { logger.warn('[NostrProvider] Signer unavailable during restore; using read-only session', {
pubkeySlice: pubkey.slice(0, 12), pubkeySlice: pk.slice(0, 12),
reason: reason instanceof Error ? reason.message : String(reason ?? '') reason: reason instanceof Error ? reason.message : String(reason ?? '')
}) })
return pubkey return pk
}
return options?.userInitiatedSwitch ? flushSync(apply) : apply()
} }
const currentAccountState = account const currentAccountState = account
let storedAccount = storage.findAccount(act) const wantedPk = accountPubkeyToHex(act.pubkey)
let storedAccount = findStoredAccountForPointer(storage.getAccounts(), act)
if (!storedAccount) { if (!storedAccount) {
if (!wantedPk) return null
if (act.signerType === 'nip-07') {
storedAccount = { pubkey: wantedPk, signerType: 'nip-07' }
} else {
return null return null
} }
}
if (storedAccount.signerType === 'nsec' || storedAccount.signerType === 'browser-nsec') { if (storedAccount.signerType === 'nsec' || storedAccount.signerType === 'browser-nsec') {
if (storedAccount.nsec) { if (storedAccount.nsec) {
const browserNsecSigner = new NsecSigner() const browserNsecSigner = new NsecSigner()
@ -1512,16 +1567,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return login(browserNsecSigner, storedAccount) return login(browserNsecSigner, storedAccount)
} }
} else if (storedAccount.signerType === 'nip-07') { } else if (storedAccount.signerType === 'nip-07') {
const targetPk =
wantedPk ?? accountPubkeyToHex(storedAccount.pubkey)
if (!targetPk) return null
try { try {
const nip07Signer = new Nip07Signer() const nip07Signer = new Nip07Signer()
await nip07Signer.init() await nip07Signer.init()
const pubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey()) const pubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey())
if (!pubkey || pubkey !== storedAccount.pubkey.toLowerCase()) { if (!pubkey || !hexPubkeysEqual(pubkey, targetPk)) {
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG) throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
} }
if (pubkey !== storedAccount.pubkey) { storedAccount = { ...storedAccount, pubkey, signerType: 'nip-07' }
storedAccount = { ...storedAccount, pubkey }
}
return login(nip07Signer, storedAccount) return login(nip07Signer, storedAccount)
} catch (err) { } catch (err) {
let lastNip07Err: unknown = err let lastNip07Err: unknown = err
@ -1531,14 +1587,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const retrySigner = new Nip07Signer() const retrySigner = new Nip07Signer()
await retrySigner.init() await retrySigner.init()
const retryPubkey = pubkeyFromNip07Extension(await retrySigner.getPublicKey()) const retryPubkey = pubkeyFromNip07Extension(await retrySigner.getPublicKey())
if (!retryPubkey || retryPubkey !== storedAccount.pubkey.toLowerCase()) { if (!retryPubkey || !hexPubkeysEqual(retryPubkey, targetPk)) {
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG) throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
} }
const act = const healed = { ...storedAccount, pubkey: retryPubkey, signerType: 'nip-07' as const }
retryPubkey !== storedAccount.pubkey return login(retrySigner, healed)
? { ...storedAccount, pubkey: retryPubkey }
: storedAccount
return login(retrySigner, act)
} catch (retryErr) { } catch (retryErr) {
lastNip07Err = retryErr lastNip07Err = retryErr
// If this tab already has a working nip-07 signer for the same account, keep it. // If this tab already has a working nip-07 signer for the same account, keep it.
@ -1549,11 +1602,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
) { ) {
try { try {
const currentPubkey = await signer.getPublicKey() const currentPubkey = await signer.getPublicKey()
if (currentPubkey.toLowerCase() === storedAccount.pubkey.toLowerCase()) { if (hexPubkeysEqual(currentPubkey, targetPk)) {
logger.info('[NostrProvider] Keeping existing NIP-07 signer after transient restore failure', { logger.info('[NostrProvider] Keeping existing NIP-07 signer after transient restore failure', {
pubkeySlice: storedAccount.pubkey.slice(0, 12) pubkeySlice: targetPk.slice(0, 12)
}) })
return storedAccount.pubkey return targetPk
} }
} catch { } catch {
// Ignore and fall through to read-only fallback. // Ignore and fall through to read-only fallback.
@ -1562,15 +1615,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
if ( if (
(isNip07SignerPubkeyMismatchError(err) || isNip07SignerPubkeyMismatchError(lastNip07Err)) && (isNip07SignerPubkeyMismatchError(err) || isNip07SignerPubkeyMismatchError(lastNip07Err)) &&
!nip07KeyMismatchToastShownRef.current options?.userInitiatedSwitch
) { ) {
if (options?.userInitiatedSwitch) { intentionalNip07ReadOnlyPubkeyRef.current = targetPk
intentionalNip07ReadOnlyPubkeyRef.current = storedAccount.pubkey.toLowerCase()
} else {
fireNip07ExtensionKeyMismatchToast()
}
} }
return fallbackToReadOnlyNpub(storedAccount.pubkey, err) return fallbackToReadOnlyNpub(targetPk, err)
} }
} else if (storedAccount.signerType === 'bunker') { } else if (storedAccount.signerType === 'bunker') {
if (storedAccount.bunker && storedAccount.bunkerClientSecretKey) { if (storedAccount.bunker && storedAccount.bunkerClientSecretKey) {
@ -1605,34 +1654,54 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return null return null
} }
/** const reconnectNip07ForPubkey = async (
* Stored NIP-07 account pubkey no longer matches the extension (user switched keys). targetPk: string,
* Drop the stale stored NIP-07 row and sign in with whatever pubkey the extension returns now. nip07Template: TAccount
*/ ): Promise<boolean> => {
const retryNip07SignerForPreferredAccount = useEventCallback(async (): Promise<boolean> => {
const preferred = storage.getCurrentAccount()
if (!preferred || preferred.signerType !== 'nip-07') return false
try { try {
const nip07Signer = new Nip07Signer() const nip07Signer = new Nip07Signer()
await nip07Signer.init() await nip07Signer.init()
const extPubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey()) const extPubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey())
if (!extPubkey || extPubkey !== preferred.pubkey.toLowerCase()) { if (!extPubkey || !hexPubkeysEqual(extPubkey, targetPk)) {
logger.info('[NostrProvider] NIP-07 reconnect: extension key mismatch', {
wantedPubkeySlice: targetPk.slice(0, 12),
extensionPubkeySlice: extPubkey?.slice(0, 12) ?? null
})
return false return false
} }
intentionalNip07ReadOnlyPubkeyRef.current = null intentionalNip07ReadOnlyPubkeyRef.current = null
nip07KeyMismatchToastShownRef.current = false const act: TAccount = { ...nip07Template, pubkey: extPubkey, signerType: 'nip-07' }
const act = storage.switchAccount(act)
extPubkey !== preferred.pubkey ? { ...preferred, pubkey: extPubkey } : preferred syncAccountPointersFromStorage()
login(nip07Signer, act) login(nip07Signer, act)
setNip07RecoveryBump((b) => b + 1) setNip07RecoveryBump((b) => b + 1)
return true return true
} catch (e) { } catch (e) {
logger.info('[NostrProvider] NIP-07 retry for preferred account failed', { logger.info('[NostrProvider] NIP-07 reconnect failed', {
pubkeySlice: preferred.pubkey.slice(0, 12), pubkeySlice: targetPk.slice(0, 12),
error: e instanceof Error ? e.message : String(e) error: e instanceof Error ? e.message : String(e)
}) })
return false return false
} }
}
/** Reconnect NIP-07 for the active read-only session (or stored preferred row). */
const retryNip07SignerForPreferredAccount = useEventCallback(async (): Promise<boolean> => {
const sessionPk =
account?.signerType === 'npub' && account.pubkey
? accountPubkeyToHex(account.pubkey)
: null
if (!sessionPk) {
const preferred = storage.getCurrentAccount()
if (!preferred || preferred.signerType !== 'nip-07') return false
const preferredPk = accountPubkeyToHex(preferred.pubkey)
if (!preferredPk) return false
return reconnectNip07ForPubkey(preferredPk, preferred)
}
const nip07Row = storage
.getAccounts()
.find((a) => a.signerType === 'nip-07' && hexPubkeysEqual(a.pubkey, sessionPk))
return reconnectNip07ForPubkey(sessionPk, nip07Row ?? { pubkey: sessionPk, signerType: 'nip-07' })
}) })
const adoptCurrentExtensionNip07Identity = useEventCallback(async () => { const adoptCurrentExtensionNip07Identity = useEventCallback(async () => {
@ -1663,35 +1732,76 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
}) })
const fireNip07ExtensionKeyMismatchToast = useCallback(() => {
if (nip07KeyMismatchToastShownRef.current) return
nip07KeyMismatchToastShownRef.current = true
showNip07ExtensionKeyMismatchToast({
onReload: () => window.location.reload(),
onUseExtensionIdentity: () => {
void adoptCurrentExtensionNip07Identity()
}
})
}, [adoptCurrentExtensionNip07Identity])
/** /**
* If session restore temporarily fell back to read-only (`npub`) while the stored * User chose read-only browse, but the extension may already match promote quietly.
* account is still `nip-07`, periodically retry reconnecting the extension signer.
*/ */
useEffect(() => { useEffect(() => {
if (!account || account.signerType !== 'npub') return if (!account || account.signerType !== 'npub') return
const intentionalPk = intentionalNip07ReadOnlyPubkeyRef.current const intentionalPk = intentionalNip07ReadOnlyPubkeyRef.current
if ( const sessionPk = accountPubkeyToHex(account.pubkey)
intentionalPk && if (!intentionalPk || !sessionPk || !hexPubkeysEqual(sessionPk, intentionalPk)) {
hexPubkeysEqual(normalizeHexPubkey(account.pubkey), intentionalPk)
) {
return return
} }
const preferred = storage.getCurrentAccount()
if (!preferred || preferred.signerType !== 'nip-07') return let cancelled = false
if (!hexPubkeysEqual(normalizeHexPubkey(preferred.pubkey), normalizeHexPubkey(account.pubkey))) { let promotionStopped = false
const tryPromote = async () => {
if (promotionStopped) return
const nip07Row = storage
.getAccounts()
.find(
(a) => a.signerType === 'nip-07' && hexPubkeysEqual(a.pubkey, intentionalPk)
)
if (!nip07Row) return
try {
if (cancelled) return
const ok = await reconnectNip07ForPubkey(intentionalPk, nip07Row)
if (ok) {
logger.info('[NostrProvider] Promoted intentional read-only session to NIP-07', {
pubkeySlice: intentionalPk.slice(0, 12)
})
promotionStopped = true
} else {
// Extension key differs — stay read-only; avoid reconnect spam in the console.
promotionStopped = true
}
} catch {
// Extension not ready — stay read-only until the user switches account.
promotionStopped = true
}
}
void tryPromote()
const id = window.setInterval(() => {
if (promotionStopped) {
window.clearInterval(id)
return return
} }
void tryPromote()
}, 2_500)
return () => {
cancelled = true
window.clearInterval(id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account])
useEffect(() => {
if (!account || account.signerType !== 'npub') return
const intentionalPk = intentionalNip07ReadOnlyPubkeyRef.current
if (intentionalPk) return
const sessionPk = accountPubkeyToHex(account.pubkey)
if (!sessionPk) return
const preferred = storage.getCurrentAccount()
const recoverPk =
preferred && hexPubkeysEqual(preferred.pubkey, sessionPk)
? accountPubkeyToHex(preferred.pubkey)
: sessionPk
if (!recoverPk) return
const nip07Row = storage
.getAccounts()
.find((a) => a.signerType === 'nip-07' && hexPubkeysEqual(a.pubkey, recoverPk))
if (!nip07Row) return
let cancelled = false let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
@ -1710,33 +1820,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (cancelled || attempts >= maxAttempts) return if (cancelled || attempts >= maxAttempts) return
attempts += 1 attempts += 1
try { try {
const nip07Signer = new Nip07Signer() const ok = await reconnectNip07ForPubkey(recoverPk, nip07Row)
await nip07Signer.init() if (ok) {
const pubkey = await nip07Signer.getPublicKey()
if (pubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) {
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
}
login(nip07Signer, preferred)
logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', { logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', {
pubkeySlice: pubkey.slice(0, 12), pubkeySlice: recoverPk.slice(0, 12),
attempts attempts
}) })
return return
}
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
} catch (error) { } catch (error) {
if (isNip07SignerPubkeyMismatchError(error)) { if (isNip07SignerPubkeyMismatchError(error)) {
logger.info('[NostrProvider] NIP-07 recovery: extension key mismatch on attempt', { logger.info('[NostrProvider] NIP-07 recovery: extension key mismatch on attempt', {
attempts, attempts,
wantedPubkey: preferred.pubkey.slice(0, 12) wantedPubkey: recoverPk.slice(0, 12)
}) })
if (intentionalNip07ReadOnlyPubkeyRef.current) {
return
}
fireNip07ExtensionKeyMismatchToast()
schedule(3_000)
return return
} }
logger.info('[NostrProvider] NIP-07 recovery retry failed', { logger.info('[NostrProvider] NIP-07 recovery retry failed', {
pubkeySlice: preferred.pubkey.slice(0, 12), pubkeySlice: recoverPk.slice(0, 12),
attempts, attempts,
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
}) })
@ -1752,7 +1854,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// nip07RecoveryBump is incremented by switchAccount after it updates storage following an // nip07RecoveryBump is incremented by switchAccount after it updates storage following an
// npub fallback, so the loop re-fires with the correct preferred account. // npub fallback, so the loop re-fires with the correct preferred account.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [account, nip07RecoveryBump, fireNip07ExtensionKeyMismatchToast]) }, [account, nip07RecoveryBump])
const normalizeDraftEventTags = ( const normalizeDraftEventTags = (
draftEvent: TDraftEvent, draftEvent: TDraftEvent,
@ -1796,7 +1898,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
{ minPow = 0, ...options }: TPublishOptions = {} { minPow = 0, ...options }: TPublishOptions = {}
) => { ) => {
if (!account || !signer || account.signerType === 'npub') { if (!account || !signer || account.signerType === 'npub') {
setOpenLoginDialog(true)
throw new LoginRequiredError() throw new LoginRequiredError()
} }
@ -1980,7 +2081,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const attemptDelete = async (targetEvent: Event) => { const attemptDelete = async (targetEvent: Event) => {
if (!signer || account?.signerType === 'npub') { if (!signer || account?.signerType === 'npub') {
setOpenLoginDialog(true)
return return
} }
if (account?.pubkey !== targetEvent.pubkey) { if (account?.pubkey !== targetEvent.pubkey) {
@ -2041,7 +2141,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const checkLogin = async <T,>(cb?: () => T | Promise<T>): Promise<T | void> => { const checkLogin = async <T,>(cb?: () => T | Promise<T>): Promise<T | void> => {
if (!signer || account?.signerType === 'npub') { if (account?.signerType === 'npub') {
return
}
if (!signer) {
setOpenLoginDialog(true) setOpenLoginDialog(true)
return return
} }
@ -2231,7 +2334,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
}, [account]) }, [account])
const startLogin = useCallback(() => setOpenLoginDialog(true), []) const startLogin = useCallback(() => {
if (account?.signerType === 'npub') return
setOpenLoginDialog(true)
}, [account?.signerType])
const removeAccountStable = useEventCallback(removeAccount) const removeAccountStable = useEventCallback(removeAccount)
const discardLocalPrivateKeyStable = useEventCallback(discardLocalPrivateKey) const discardLocalPrivateKeyStable = useEventCallback(discardLocalPrivateKey)

20
src/providers/NostrProvider/nip-07.signer.ts

@ -1,5 +1,22 @@
import { pubkeyFromNip07Extension } from '@/lib/pubkey'
import { ISigner, TDraftEvent, TNip07 } from '@/types' import { ISigner, TDraftEvent, TNip07 } from '@/types'
/** Fresh extension pubkey (hex), after init + optional enable. */
export async function getExtensionPubkeyHex(): Promise<string> {
const signer = new Nip07Signer()
await signer.init()
const raw = await signer.getPublicKey()
const hex = pubkeyFromNip07Extension(raw)
if (!hex) {
throw new Error(
raw
? 'Extension returned an invalid pubkey'
: 'You did not allow the extension to access your pubkey'
)
}
return hex
}
export class Nip07Signer implements ISigner { export class Nip07Signer implements ISigner {
private signer: TNip07 | undefined private signer: TNip07 | undefined
private pubkey: string | null = null private pubkey: string | null = null
@ -13,6 +30,9 @@ export class Nip07Signer implements ISigner {
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (window.nostr) { if (window.nostr) {
this.signer = window.nostr this.signer = window.nostr
if (typeof this.signer.enable === 'function') {
await this.signer.enable()
}
return return
} }
await new Promise((resolve) => setTimeout(resolve, checkInterval)) await new Promise((resolve) => setTimeout(resolve, checkInterval))

2
src/types/index.d.ts vendored

@ -147,6 +147,8 @@ export type TFontSize = 'small' | 'medium' | 'large'
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'> export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>
export type TNip07 = { export type TNip07 = {
/** Optional permission prompt (Alby, nos2x, …). */
enable?: () => Promise<unknown>
getPublicKey: () => Promise<string> getPublicKey: () => Promise<string>
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent> signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04?: { nip04?: {

Loading…
Cancel
Save