Browse Source

refactor refresh

imwald
Silberengel 2 weeks ago
parent
commit
e13d3cfada
  1. 20
      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. 318
      src/providers/NostrProvider/index.tsx
  24. 20
      src/providers/NostrProvider/nip-07.signer.ts
  25. 2
      src/types/index.d.ts

20
src/components/AccountList/index.tsx

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

16
src/components/AccountQuickSwitchMenuItems.tsx

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

16
src/components/FollowButton/index.tsx

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

41
src/components/HelpAndAccountMenu.tsx

@ -11,7 +11,14 @@ import { @@ -11,7 +11,14 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
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 { cn } from '@/lib/utils'
import { openBrowseCacheFromRegistry } from '@/contexts/cache-browser-context'
@ -27,6 +34,20 @@ import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows' @@ -27,6 +34,20 @@ import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react'
import { useCallback, useMemo, useState, type ReactNode } from 'react'
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 =
'z-[220] w-[min(18rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain'
@ -92,6 +113,10 @@ function SidebarAccountMenu({ @@ -92,6 +113,10 @@ function SidebarAccountMenu({
const [menuOpen, setMenuOpen] = useState(false)
const pubkey = account?.pubkey
const { profile: fetchedProfile } = useFetchProfile(pubkey)
const resolvedProfile = useMemo(
() => profileForActivePubkey(pubkey, profile, fetchedProfile),
[pubkey, profile, fetchedProfile]
)
const active = useMemo(() => current === 'profile' && display, [display, current])
if (!pubkey) return null
@ -99,8 +124,9 @@ function SidebarAccountMenu({ @@ -99,8 +124,9 @@ function SidebarAccountMenu({
const defaultAvatar = generateImageByPubkey(pubkey)
const npub = pubkeyToNpub(pubkey)
const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey)
const resolvedProfile = fetchedProfile ?? profile
const { username, avatar } = resolvedProfile || { username: fallbackUsername, avatar: defaultAvatar }
const { username, avatar } = resolvedProfile
? { username: resolvedProfile.username, avatar: resolvedProfile.avatar ?? defaultAvatar }
: { username: fallbackUsername, avatar: defaultAvatar }
return (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
@ -121,7 +147,7 @@ function SidebarAccountMenu({ @@ -121,7 +147,7 @@ function SidebarAccountMenu({
<video src={avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
</div>
) : (
<Avatar className="size-8 shrink-0">
<Avatar className="size-8 shrink-0" key={pubkey}>
<AvatarImage src={avatar || defaultAvatar} className="object-cover object-center" />
<AvatarFallback delayMs={0}>
<AvatarIdenticon src={defaultAvatar} />
@ -156,7 +182,10 @@ function TitlebarAccountMenu({ @@ -156,7 +182,10 @@ function TitlebarAccountMenu({
const { account, profile } = useNostr()
const pubkey = account?.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 [menuOpen, setMenuOpen] = useState(false)
const defaultAvatar = useMemo(
@ -181,7 +210,7 @@ function TitlebarAccountMenu({ @@ -181,7 +210,7 @@ function TitlebarAccountMenu({
<video src={resolvedProfile.avatar} className="h-full w-full object-cover object-center" autoPlay muted loop playsInline />
</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
src={resolvedProfile.avatar || defaultAvatar}
className="object-cover object-center"

12
src/components/MuteButton/index.tsx

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

51
src/components/Nip07ExtensionKeyMismatchToast/index.tsx

@ -1,51 +0,0 @@ @@ -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 { @@ -20,6 +20,7 @@ import {
isDiscussionVoteEmoji
} from '@/lib/discussion-votes'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { eventService } from '@/services/client.service'
@ -63,6 +64,7 @@ export function LikeButtonWithStats({ @@ -63,6 +64,7 @@ export function LikeButtonWithStats({
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr()
const { canSignEvents, signControlProps } = useSignGatedControl()
const { relays: statsRelays } = useNoteStatsRelayHints()
const [liking, setLiking] = useState(false)
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
@ -234,6 +236,7 @@ export function LikeButtonWithStats({ @@ -234,6 +236,7 @@ export function LikeButtonWithStats({
}
const openReactionPicker = () => {
if (!canSignEvents) return
if (myLastEmoji && !isEmojiReactionsOpen) {
like(myLastEmoji)
return
@ -245,8 +248,7 @@ export function LikeButtonWithStats({ @@ -245,8 +248,7 @@ export function LikeButtonWithStats({
<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"
title={t('Like')}
disabled={liking}
{...signControlProps({ title: t('Like'), disabled: liking })}
onClick={openReactionPicker}
>
{liking ? (
@ -289,8 +291,10 @@ export function LikeButtonWithStats({ @@ -289,8 +291,10 @@ export function LikeButtonWithStats({
<button
type="button"
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')}
disabled={liking}
{...signControlProps({
title: emoji === '+' ? t('Upvote') : t('Downvote'),
disabled: liking
})}
onClick={() => {
like(emoji)
}}

5
src/components/NoteStats/ReplyButton.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { cn } from '@/lib/utils'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider'
import type { TNoteStats } from '@/services/note-stats.service'
import { MessageCircle } from 'lucide-react'
@ -18,6 +19,7 @@ type ReplyButtonProps = { @@ -18,6 +19,7 @@ type ReplyButtonProps = {
export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: ReplyButtonProps) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { signControlProps } = useSignGatedControl()
const { replyCount, hasReplied } = useMemo(() => {
const hasReplied = pubkey
? noteStats?.replies?.some((reply) => reply.pubkey === pubkey)
@ -39,6 +41,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re @@ -39,6 +41,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
return (
<>
<button
type="button"
className={cn(
'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'
@ -49,7 +52,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re @@ -49,7 +52,7 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
setOpen(true)
})
}}
title={t('Reply')}
{...signControlProps({ title: t('Reply') })}
>
<MessageCircle />
{!hideCount && replyCountLabel !== '' && (

9
src/components/NoteStats/RepostButton.tsx

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

4
src/components/PostEditor/PostContent.tsx

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

86
src/components/StoredAccountSwitchSelect.tsx

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

1
src/hooks/index.tsx

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

6
src/hooks/useFetchProfile.tsx

@ -6,7 +6,7 @@ import { @@ -6,7 +6,7 @@ import {
isPubkeyAwaitingProfileBatch,
shouldDeferPerPubkeyProfileNetwork
} 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 { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { eventService, replaceableEventService } from '@/services/client.service'
@ -578,6 +578,10 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -578,6 +578,10 @@ export function useFetchProfile(id?: string, skipCache = false) {
processingPubkeyRef.current = null
}
if (extractedPubkey && profile && !hexPubkeysEqual(profile.pubkey, extractedPubkey)) {
setProfile(null)
}
const cancelled = { current: false }
if (!extractedPubkey) {

20
src/hooks/useSignGatedControl.ts

@ -0,0 +1,20 @@ @@ -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 { @@ -1002,6 +1002,8 @@ export default {
'accountSwitch.extensionConnected': 'Erweiterung für dieses Konto verbunden.',
'accountSwitch.extensionRetryFailed':
'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',
'Hide untrusted {type}': 'Untrusted {{type}} ausblenden',
'Currently hiding {type} from untrusted users.':

2
src/i18n/locales/en.ts

@ -1019,6 +1019,8 @@ export default { @@ -1019,6 +1019,8 @@ export default {
'accountSwitch.extensionConnected': 'Extension connected for this account.',
'accountSwitch.extensionRetryFailed':
'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}}',
'Hide untrusted {type}': 'Hide untrusted {{type}}',
'Currently hiding {type} from untrusted users.':

24
src/lib/account.test.ts

@ -1,5 +1,12 @@ @@ -1,5 +1,12 @@
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', () => {
const A = 'a'.repeat(64)
@ -39,6 +46,17 @@ describe('isSameAccount', () => { @@ -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', () => {
const A = 'a'.repeat(64)
@ -47,12 +65,12 @@ describe('isRedundantAccountPick', () => { @@ -47,12 +65,12 @@ describe('isRedundantAccountPick', () => {
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(
isRedundantAccountPick(
{ pubkey: A, signerType: 'nip-07' },
{ pubkey: A, signerType: 'npub' }
)
).toBe(false)
).toBe(true)
})
})

35
src/lib/account.ts

@ -1,16 +1,16 @@ @@ -1,16 +1,16 @@
import { hexPubkeysEqual, isValidPubkey, normalizeHexPubkey } from '@/lib/pubkey'
import { TAccountPointer, TSignerType } from '@/types'
import { accountPubkeyToHex, hexPubkeysEqual } from '@/lib/pubkey'
import { TAccount, TAccountPointer, TSignerType } from '@/types'
export function isSameAccount(a: TAccountPointer | null, b: TAccountPointer | null) {
if (!a || !b) 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). */
export function isSameAccountPubkey(a: TAccountPointer | null, b: TAccountPointer | null) {
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). */
@ -24,6 +24,14 @@ export function isRedundantAccountPick(target: TAccountPointer, session: TAccoun @@ -24,6 +24,14 @@ export function isRedundantAccountPick(target: TAccountPointer, session: TAccoun
) {
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
}
@ -39,8 +47,7 @@ const SWITCH_SIGNER_PRIORITY: Record<TSignerType, number> = { @@ -39,8 +47,7 @@ const SWITCH_SIGNER_PRIORITY: Record<TSignerType, number> = {
function normalizedPubkeyHex(account: TAccountPointer): string | null {
const raw = account.pubkey?.trim()
if (!raw) return null
const pk = normalizeHexPubkey(raw)
return isValidPubkey(pk) ? pk : null
return accountPubkeyToHex(raw)
}
/**
@ -71,3 +78,19 @@ export function accountPointerKey(account: TAccountPointer): string { @@ -71,3 +78,19 @@ export function accountPointerKey(account: TAccountPointer): string {
const pk = normalizedPubkeyHex(account)
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 @@ @@ -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 @@ @@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getPublicKey, generateSecretKey, nip19 } from 'nostr-tools'
import { pubkeyFromNip07Extension } from './pubkey'
import { hexPubkeysEqual, pubkeyFromNip07Extension } from './pubkey'
describe('pubkeyFromNip07Extension', () => {
it('accepts hex pubkey', () => {
@ -21,3 +21,13 @@ describe('pubkeyFromNip07Extension', () => { @@ -21,3 +21,13 @@ describe('pubkeyFromNip07Extension', () => {
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 { @@ -81,16 +81,17 @@ export function normalizeHexPubkey(pubkey: string): string {
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 {
if (a === b) return true
const na = normalizeHexPubkey(a)
const nb = normalizeHexPubkey(b)
return (
na.length === 64 &&
nb.length === 64 &&
/^[0-9a-f]{64}$/.test(na) &&
na === nb
)
const ha = accountPubkeyToHex(a)
const hb = accountPubkeyToHex(b)
return ha !== null && hb !== null && ha === hb
}
export function isValidPubkey(pubkey: string) {

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

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

318
src/providers/NostrProvider/index.tsx

@ -47,7 +47,15 @@ import { @@ -47,7 +47,15 @@ import {
} from '@/lib/viewer-blocked-relays'
import { LoginRequiredError } from '@/lib/nostr-errors'
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 client from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
@ -75,9 +83,8 @@ import { NostrContext, type TNostrContext } from '@/providers/nostr-context' @@ -75,9 +83,8 @@ import { NostrContext, type TNostrContext } from '@/providers/nostr-context'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEventCallback } from '@/hooks/use-event-callback'
import { useTranslation } from 'react-i18next'
import { isSameAccount } from '@/lib/account'
import { findStoredAccountForPointer, isSameAccount } from '@/lib/account'
import { flushSync } from 'react-dom'
import { showNip07ExtensionKeyMismatchToast } from '@/lib/nip07-extension-key-mismatch-toast'
import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer'
import { Nip07Signer } from './nip-07.signer'
@ -146,7 +153,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -146,7 +153,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [ncryptsecPasswordOpen, setNcryptsecPasswordOpen] = useState(false)
const ncryptsecPasswordResolveRef = useRef<((value: string | null) => void) | null>(null)
/** 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
* 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 }) { @@ -1172,7 +1178,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const normalized = normalizeLoginAccount(act)
const prev = accountForReplaceablesSyncRef.current
if (normalized.signerType === 'nip-07') {
nip07KeyMismatchToastShownRef.current = false
intentionalNip07ReadOnlyPubkeyRef.current = null
}
storage.addAccount(normalized)
@ -1252,7 +1257,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1252,7 +1257,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// 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.
if (result !== null) {
const storedFull = storage.findAccount(act)
const storedFull = findStoredAccountForPointer(storage.getAccounts(), act)
if (storedFull && !isSameAccount(storage.getCurrentAccount(), storedFull)) {
storage.switchAccount(storedFull)
syncAccountPointersFromStorage()
@ -1262,7 +1267,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1262,7 +1267,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
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 stored = storage.findAccount(act)
const normalized = normalizeLoginAccount(
@ -1270,8 +1275,30 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1270,8 +1275,30 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
)
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()
nip07KeyMismatchToastShownRef.current = true
const storageRow: TAccount =
stored ??
@ -1401,18 +1428,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1401,18 +1428,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
const raw = await nip07Signer.getPublicKey()
const pubkey = pubkeyFromNip07Extension(raw)
const pubkey = pubkeyFromNip07Extension(await nip07Signer.getPublicKey())
if (!pubkey) {
throw new Error(
raw
? 'Extension returned an invalid pubkey'
: 'You did not allow to access your pubkey'
)
throw new Error('Extension returned an invalid pubkey')
}
const readOnlyDup = storage
.getAccounts()
.find((a) => a.pubkey === pubkey && a.signerType === 'npub')
.find((a) => a.signerType === 'npub' && hexPubkeysEqual(a.pubkey, pubkey))
if (readOnlyDup) {
storage.removeAccount(readOnlyDup)
syncAccountPointersFromStorage()
@ -1464,23 +1486,56 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1464,23 +1486,56 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
options?: { userInitiatedSwitch?: boolean }
): Promise<string | null> => {
const fallbackToReadOnlyNpub = (pubkey: string, reason?: unknown): string => {
const npubSigner = new NpubSigner()
const npub = nip19.npubEncode(pubkey)
npubSigner.login(npub)
// Keep this fallback in-memory only; do not rewrite stored account type.
setAccount({ pubkey, signerType: 'npub' })
setSigner(npubSigner)
logger.warn('[NostrProvider] Signer unavailable during restore; using read-only session', {
pubkeySlice: pubkey.slice(0, 12),
reason: reason instanceof Error ? reason.message : String(reason ?? '')
})
return pubkey
const pk =
accountPubkeyToHex(pubkey) ??
(isValidPubkey(normalizeHexPubkey(pubkey)) ? normalizeHexPubkey(pubkey) : null)
if (!pk) return pubkey
const apply = (): string => {
const npubSigner = new NpubSigner()
npubSigner.login(nip19.npubEncode(pk))
const prev = accountForReplaceablesSyncRef.current
const sessionChanged =
!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)
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', {
pubkeySlice: pk.slice(0, 12),
reason: reason instanceof Error ? reason.message : String(reason ?? '')
})
return pk
}
return options?.userInitiatedSwitch ? flushSync(apply) : apply()
}
const currentAccountState = account
let storedAccount = storage.findAccount(act)
const wantedPk = accountPubkeyToHex(act.pubkey)
let storedAccount = findStoredAccountForPointer(storage.getAccounts(), act)
if (!storedAccount) {
return null
if (!wantedPk) return null
if (act.signerType === 'nip-07') {
storedAccount = { pubkey: wantedPk, signerType: 'nip-07' }
} else {
return null
}
}
if (storedAccount.signerType === 'nsec' || storedAccount.signerType === 'browser-nsec') {
if (storedAccount.nsec) {
@ -1512,16 +1567,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1512,16 +1567,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return login(browserNsecSigner, storedAccount)
}
} else if (storedAccount.signerType === 'nip-07') {
const targetPk =
wantedPk ?? accountPubkeyToHex(storedAccount.pubkey)
if (!targetPk) return null
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
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)
}
if (pubkey !== storedAccount.pubkey) {
storedAccount = { ...storedAccount, pubkey }
}
storedAccount = { ...storedAccount, pubkey, signerType: 'nip-07' }
return login(nip07Signer, storedAccount)
} catch (err) {
let lastNip07Err: unknown = err
@ -1531,14 +1587,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1531,14 +1587,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const retrySigner = new Nip07Signer()
await retrySigner.init()
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)
}
const act =
retryPubkey !== storedAccount.pubkey
? { ...storedAccount, pubkey: retryPubkey }
: storedAccount
return login(retrySigner, act)
const healed = { ...storedAccount, pubkey: retryPubkey, signerType: 'nip-07' as const }
return login(retrySigner, healed)
} catch (retryErr) {
lastNip07Err = retryErr
// 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 }) { @@ -1549,11 +1602,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
) {
try {
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', {
pubkeySlice: storedAccount.pubkey.slice(0, 12)
pubkeySlice: targetPk.slice(0, 12)
})
return storedAccount.pubkey
return targetPk
}
} catch {
// Ignore and fall through to read-only fallback.
@ -1562,15 +1615,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1562,15 +1615,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
if (
(isNip07SignerPubkeyMismatchError(err) || isNip07SignerPubkeyMismatchError(lastNip07Err)) &&
!nip07KeyMismatchToastShownRef.current
options?.userInitiatedSwitch
) {
if (options?.userInitiatedSwitch) {
intentionalNip07ReadOnlyPubkeyRef.current = storedAccount.pubkey.toLowerCase()
} else {
fireNip07ExtensionKeyMismatchToast()
}
intentionalNip07ReadOnlyPubkeyRef.current = targetPk
}
return fallbackToReadOnlyNpub(storedAccount.pubkey, err)
return fallbackToReadOnlyNpub(targetPk, err)
}
} else if (storedAccount.signerType === 'bunker') {
if (storedAccount.bunker && storedAccount.bunkerClientSecretKey) {
@ -1605,34 +1654,54 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1605,34 +1654,54 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return null
}
/**
* Stored NIP-07 account pubkey no longer matches the extension (user switched keys).
* Drop the stale stored NIP-07 row and sign in with whatever pubkey the extension returns now.
*/
const retryNip07SignerForPreferredAccount = useEventCallback(async (): Promise<boolean> => {
const preferred = storage.getCurrentAccount()
if (!preferred || preferred.signerType !== 'nip-07') return false
const reconnectNip07ForPubkey = async (
targetPk: string,
nip07Template: TAccount
): Promise<boolean> => {
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
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
}
intentionalNip07ReadOnlyPubkeyRef.current = null
nip07KeyMismatchToastShownRef.current = false
const act =
extPubkey !== preferred.pubkey ? { ...preferred, pubkey: extPubkey } : preferred
const act: TAccount = { ...nip07Template, pubkey: extPubkey, signerType: 'nip-07' }
storage.switchAccount(act)
syncAccountPointersFromStorage()
login(nip07Signer, act)
setNip07RecoveryBump((b) => b + 1)
return true
} catch (e) {
logger.info('[NostrProvider] NIP-07 retry for preferred account failed', {
pubkeySlice: preferred.pubkey.slice(0, 12),
logger.info('[NostrProvider] NIP-07 reconnect failed', {
pubkeySlice: targetPk.slice(0, 12),
error: e instanceof Error ? e.message : String(e)
})
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 () => {
@ -1663,36 +1732,77 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1663,36 +1732,77 @@ 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
* account is still `nip-07`, periodically retry reconnecting the extension signer.
* User chose read-only browse, but the extension may already match promote quietly.
*/
useEffect(() => {
if (!account || account.signerType !== 'npub') return
const intentionalPk = intentionalNip07ReadOnlyPubkeyRef.current
if (
intentionalPk &&
hexPubkeysEqual(normalizeHexPubkey(account.pubkey), intentionalPk)
) {
const sessionPk = accountPubkeyToHex(account.pubkey)
if (!intentionalPk || !sessionPk || !hexPubkeysEqual(sessionPk, intentionalPk)) {
return
}
const preferred = storage.getCurrentAccount()
if (!preferred || preferred.signerType !== 'nip-07') return
if (!hexPubkeysEqual(normalizeHexPubkey(preferred.pubkey), normalizeHexPubkey(account.pubkey))) {
return
let cancelled = false
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
}
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 timer: ReturnType<typeof setTimeout> | null = null
let attempts = 0
@ -1710,33 +1820,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1710,33 +1820,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (cancelled || attempts >= maxAttempts) return
attempts += 1
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
const pubkey = await nip07Signer.getPublicKey()
if (pubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) {
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
const ok = await reconnectNip07ForPubkey(recoverPk, nip07Row)
if (ok) {
logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', {
pubkeySlice: recoverPk.slice(0, 12),
attempts
})
return
}
login(nip07Signer, preferred)
logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', {
pubkeySlice: pubkey.slice(0, 12),
attempts
})
return
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
} catch (error) {
if (isNip07SignerPubkeyMismatchError(error)) {
logger.info('[NostrProvider] NIP-07 recovery: extension key mismatch on attempt', {
attempts,
wantedPubkey: preferred.pubkey.slice(0, 12)
wantedPubkey: recoverPk.slice(0, 12)
})
if (intentionalNip07ReadOnlyPubkeyRef.current) {
return
}
fireNip07ExtensionKeyMismatchToast()
schedule(3_000)
return
}
logger.info('[NostrProvider] NIP-07 recovery retry failed', {
pubkeySlice: preferred.pubkey.slice(0, 12),
pubkeySlice: recoverPk.slice(0, 12),
attempts,
error: error instanceof Error ? error.message : String(error)
})
@ -1752,7 +1854,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1752,7 +1854,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// 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, fireNip07ExtensionKeyMismatchToast])
}, [account, nip07RecoveryBump])
const normalizeDraftEventTags = (
draftEvent: TDraftEvent,
@ -1796,7 +1898,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1796,7 +1898,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
{ minPow = 0, ...options }: TPublishOptions = {}
) => {
if (!account || !signer || account.signerType === 'npub') {
setOpenLoginDialog(true)
throw new LoginRequiredError()
}
@ -1980,7 +2081,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1980,7 +2081,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const attemptDelete = async (targetEvent: Event) => {
if (!signer || account?.signerType === 'npub') {
setOpenLoginDialog(true)
return
}
if (account?.pubkey !== targetEvent.pubkey) {
@ -2041,7 +2141,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -2041,7 +2141,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
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)
return
}
@ -2231,7 +2334,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -2231,7 +2334,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
})
}, [account])
const startLogin = useCallback(() => setOpenLoginDialog(true), [])
const startLogin = useCallback(() => {
if (account?.signerType === 'npub') return
setOpenLoginDialog(true)
}, [account?.signerType])
const removeAccountStable = useEventCallback(removeAccount)
const discardLocalPrivateKeyStable = useEventCallback(discardLocalPrivateKey)

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

@ -1,5 +1,22 @@ @@ -1,5 +1,22 @@
import { pubkeyFromNip07Extension } from '@/lib/pubkey'
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 {
private signer: TNip07 | undefined
private pubkey: string | null = null
@ -13,6 +30,9 @@ export class Nip07Signer implements ISigner { @@ -13,6 +30,9 @@ export class Nip07Signer implements ISigner {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (window.nostr) {
this.signer = window.nostr
if (typeof this.signer.enable === 'function') {
await this.signer.enable()
}
return
}
await new Promise((resolve) => setTimeout(resolve, checkInterval))

2
src/types/index.d.ts vendored

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

Loading…
Cancel
Save