Browse Source

anon signing

imwald
Silberengel 2 weeks ago
parent
commit
3db834ca0b
  1. 29
      src/components/AccountQuickSwitchMenuItems.tsx
  2. 25
      src/components/AnonUserAvatar.tsx
  3. 4
      src/components/ContentPreview/FollowPackPreview.tsx
  4. 4
      src/components/FollowButton/index.tsx
  5. 61
      src/components/HelpAndAccountMenu.tsx
  6. 4
      src/components/MuteButton/index.tsx
  7. 11
      src/components/NoteOptions/useMenuActions.tsx
  8. 4
      src/components/NotificationThreadWatchButtons/index.tsx
  9. 7
      src/components/ProfileOptions/index.tsx
  10. 62
      src/components/StoredAccountSwitchSelect.tsx
  11. 13
      src/hooks/useSignGatedControl.ts
  12. 9
      src/i18n/locales/en.ts
  13. 18
      src/lib/account.ts
  14. 42
      src/lib/anon-session.ts
  15. 139
      src/providers/NostrProvider/index.tsx
  16. 4
      src/providers/nostr-context.tsx
  17. 10
      src/services/client.service.ts
  18. 10
      src/types/index.d.ts

29
src/components/AccountQuickSwitchMenuItems.tsx

@ -1,3 +1,4 @@
import { AnonUserAvatar } from '@/components/AnonUserAvatar'
import { SimpleUserAvatar } from '@/components/UserAvatar' import { SimpleUserAvatar } from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username' import { SimpleUsername } from '@/components/Username'
import { import {
@ -7,6 +8,8 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { import {
accountPointerKey, accountPointerKey,
createAnonAccountPointer,
isAnonAccount,
isRedundantAccountPick, isRedundantAccountPick,
isSameAccountPubkey, isSameAccountPubkey,
listSwitchableAccounts listSwitchableAccounts
@ -19,19 +22,32 @@ import { Check } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
const anonAccount = createAnonAccountPointer()
export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?: () => void }) { export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
accounts, accounts,
account, account,
isAnonSession,
switchAccount, switchAccount,
retryNip07SignerForPreferredAccount retryNip07SignerForPreferredAccount
} = useNostr() } = useNostr()
const rows = listSwitchableAccounts(accounts) const rows = listSwitchableAccounts(accounts)
if (rows.length <= 1) return null if (rows.length === 0 && !isAnonSession) return null
const handleSwitch = async (act: TAccountPointer) => { const handleSwitch = async (act: TAccountPointer) => {
if (isAnonAccount(act)) {
if (isAnonSession) {
onAfterSwitch?.()
return
}
await switchAccount(act)
onAfterSwitch?.()
return
}
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) const switched = await switchAccount(act)
@ -62,8 +78,19 @@ export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?:
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
{t('notificationsViewAsAccount')} {t('notificationsViewAsAccount')}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuItem className="gap-2" onClick={() => void handleSwitch(anonAccount)}>
<AnonUserAvatar size="small" className="size-8" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{t('accountSwitch.anon')}</span>
<span className="block truncate text-xs text-muted-foreground">
{t('accountSwitch.anonHintShort')}
</span>
</span>
<Check className={cn('size-4 shrink-0', isAnonSession ? 'opacity-100' : 'opacity-0')} aria-hidden />
</DropdownMenuItem>
{rows.map((act) => { {rows.map((act) => {
const active = const active =
!isAnonSession &&
account != null && account != null &&
isSameAccountPubkey(act, account) && isSameAccountPubkey(act, account) &&
(account.signerType === act.signerType || (account.signerType === act.signerType ||

25
src/components/AnonUserAvatar.tsx

@ -0,0 +1,25 @@
import { cn } from '@/lib/utils'
import { UserRound } from 'lucide-react'
export function AnonUserAvatar({
size = 'small',
className
}: {
size?: 'small' | 'medium'
className?: string
}) {
const dim = size === 'small' ? 'size-8' : 'size-10'
const icon = size === 'small' ? 'size-4' : 'size-5'
return (
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground ring-1 ring-border/60',
dim,
className
)}
aria-hidden
>
<UserRound className={icon} strokeWidth={2} />
</div>
)
}

4
src/components/ContentPreview/FollowPackPreview.tsx

@ -39,7 +39,7 @@ export default function FollowPackPreview({
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey, canManageIdentity } = useNostr()
const followList = useFollowListOptional() const followList = useFollowListOptional()
const followings = followList?.followings ?? [] const followings = followList?.followings ?? []
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
@ -169,7 +169,7 @@ export default function FollowPackPreview({
) : null} ) : null}
</div> </div>
{!pubkey ? ( {!canManageIdentity ? (
<p className="text-sm text-muted-foreground">{t('Please log in to follow')}</p> <p className="text-sm text-muted-foreground">{t('Please log in to follow')}</p>
) : !followList ? null : ( ) : !followList ? null : (
<Button <Button

4
src/components/FollowButton/index.tsx

@ -23,7 +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 { canManageIdentity, 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)
@ -33,7 +33,7 @@ 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 || !canSignEvents || (pubkey && pubkey === accountPubkey)) { if (!followList || !accountPubkey || !canManageIdentity || (pubkey && pubkey === accountPubkey)) {
return null return null
} }

61
src/components/HelpAndAccountMenu.tsx

@ -28,6 +28,7 @@ import { useSmartSettingsNavigation } from '@/PageManager'
import { useFetchProfile } from '@/hooks/useFetchProfile' import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { AccountQuickSwitchMenuItems } from '@/components/AccountQuickSwitchMenuItems' import { AccountQuickSwitchMenuItems } from '@/components/AccountQuickSwitchMenuItems'
import { AnonUserAvatar } from '@/components/AnonUserAvatar'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
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'
@ -65,16 +66,32 @@ function AccountDropdownItems({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const { isAnonSession } = useNostr()
const anonIdentityDisabled = t('accountSwitch.anonIdentityDisabled')
return ( return (
<> <>
<ReadOnlySessionIndicator variant="menu" /> <ReadOnlySessionIndicator variant="menu" />
<AccountQuickSwitchMenuItems onAfterSwitch={onCloseMenu} /> <AccountQuickSwitchMenuItems onAfterSwitch={onCloseMenu} />
<DropdownMenuItem onClick={() => navigate('profile')}> <DropdownMenuItem
disabled={isAnonSession}
title={isAnonSession ? anonIdentityDisabled : undefined}
onClick={() => {
if (isAnonSession) return
navigate('profile')
}}
>
<User className="size-4" /> <User className="size-4" />
{t('Profile')} {t('Profile')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('settings')}> <DropdownMenuItem
disabled={isAnonSession}
title={isAnonSession ? anonIdentityDisabled : undefined}
onClick={() => {
if (isAnonSession) return
navigate('settings')
}}
>
<Settings className="size-4" /> <Settings className="size-4" />
{t('Settings')} {t('Settings')}
</DropdownMenuItem> </DropdownMenuItem>
@ -105,22 +122,22 @@ function SidebarAccountMenu({
onBrowseCache: () => void onBrowseCache: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { account, profile } = useNostr() const { account, profile, isAnonSession } = useNostr()
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
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(isAnonSession ? undefined : pubkey)
const resolvedProfile = useMemo( const resolvedProfile = useMemo(
() => profileForActivePubkey(pubkey, profile, fetchedProfile), () => (isAnonSession ? null : profileForActivePubkey(pubkey, profile, fetchedProfile)),
[pubkey, profile, fetchedProfile] [pubkey, profile, fetchedProfile, isAnonSession]
) )
const active = useMemo(() => current === 'profile' && display, [display, current]) const active = useMemo(() => current === 'profile' && display, [display, current])
if (!pubkey) return null if (!pubkey && !isAnonSession) return null
const defaultAvatar = generateImageByPubkey(pubkey) const defaultAvatar = pubkey ? generateImageByPubkey(pubkey) : ''
const npub = pubkeyToNpub(pubkey) const npub = pubkey ? pubkeyToNpub(pubkey) : null
const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey) const fallbackUsername = npub ? formatNpub(npub) : pubkey ? formatPubkey(pubkey) : t('accountSwitch.anon')
const { username, avatar } = resolvedProfile const { username, avatar } = resolvedProfile
? { username: resolvedProfile.username, avatar: resolvedProfile.avatar ?? defaultAvatar } ? { username: resolvedProfile.username, avatar: resolvedProfile.avatar ?? defaultAvatar }
: { username: fallbackUsername, avatar: defaultAvatar } : { username: fallbackUsername, avatar: defaultAvatar }
@ -139,7 +156,9 @@ function SidebarAccountMenu({
active && 'bg-accent/50' active && 'bg-accent/50'
)} )}
> >
{isVideo(avatar ?? '') ? ( {isAnonSession ? (
<AnonUserAvatar size="medium" className="size-8 shrink-0" />
) : isVideo(avatar ?? '') ? (
<div className="size-8 shrink-0 overflow-hidden rounded-full"> <div className="size-8 shrink-0 overflow-hidden rounded-full">
<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>
@ -151,7 +170,9 @@ function SidebarAccountMenu({
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)} )}
<span className="truncate max-xl:hidden">{username}</span> <span className="truncate max-xl:hidden">
{isAnonSession ? t('accountSwitch.anon') : username}
</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" className="z-[220]"> <DropdownMenuContent side="top" align="end" className="z-[220]">
@ -176,12 +197,12 @@ function TitlebarAccountMenu({
onBrowseCache: () => void onBrowseCache: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { account, profile } = useNostr() const { account, profile, isAnonSession } = useNostr()
const pubkey = account?.pubkey const pubkey = account?.pubkey
const { profile: fetchedProfile } = useFetchProfile(pubkey) const { profile: fetchedProfile } = useFetchProfile(isAnonSession ? undefined : pubkey)
const resolvedProfile = useMemo( const resolvedProfile = useMemo(
() => profileForActivePubkey(pubkey, profile, fetchedProfile), () => (isAnonSession ? null : profileForActivePubkey(pubkey, profile, fetchedProfile)),
[pubkey, profile, fetchedProfile] [pubkey, profile, fetchedProfile, isAnonSession]
) )
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
@ -201,7 +222,9 @@ function TitlebarAccountMenu({
title={t('Account menu')} title={t('Account menu')}
aria-label={t('Account menu')} aria-label={t('Account menu')}
> >
{resolvedProfile ? ( {isAnonSession ? (
<AnonUserAvatar size="small" className="size-6" />
) : resolvedProfile ? (
isVideo(resolvedProfile.avatar ?? '') ? ( isVideo(resolvedProfile.avatar ?? '') ? (
<div className={cn('w-6 h-6 overflow-hidden rounded-full', active ? 'ring-primary ring-1' : '')}> <div className={cn('w-6 h-6 overflow-hidden rounded-full', active ? 'ring-primary ring-1' : '')}>
<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 />
@ -246,7 +269,7 @@ function LoggedOutTitlebarMenu({ onLogin }: { onLogin: () => void }) {
/** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */ /** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) {
const { pubkey, checkLogin, isNip07LoginInFlight } = useNostr() const { pubkey, checkLogin, isNip07LoginInFlight, isAnonSession } = useNostr()
const { navigateToSettings } = useSmartSettingsNavigation() const { navigateToSettings } = useSmartSettingsNavigation()
const onBrowseCache = useCallback(() => { const onBrowseCache = useCallback(() => {
if (!openBrowseCacheFromRegistry()) { if (!openBrowseCacheFromRegistry()) {
@ -257,7 +280,7 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
let account: ReactNode let account: ReactNode
if (pubkey) { if (pubkey || isAnonSession) {
account = account =
variant === 'sidebar' ? ( variant === 'sidebar' ? (
<SidebarAccountMenu <SidebarAccountMenu

4
src/components/MuteButton/index.tsx

@ -21,13 +21,13 @@ 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 { signControlProps, canManageIdentity } = 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)
const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey]) const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey])
if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null if (!canManageIdentity || !accountPubkey || (pubkey && pubkey === accountPubkey)) return null
const handleMute = async (e: React.MouseEvent, isPrivate = true) => { const handleMute = async (e: React.MouseEvent, isPrivate = true) => {
e.stopPropagation() e.stopPropagation()

11
src/components/NoteOptions/useMenuActions.tsx

@ -163,7 +163,8 @@ export function useMenuActions({
account, account,
relayList, relayList,
bookmarkListEvent, bookmarkListEvent,
checkLogin checkLogin,
canManageIdentity
} = useNostr() } = useNostr()
const bookmarksContext = useBookmarksOptional() const bookmarksContext = useBookmarksOptional()
const { threadFollowed, threadMuted, threadWatch } = useThreadNotificationMenuState(event) const { threadFollowed, threadMuted, threadWatch } = useThreadNotificationMenuState(event)
@ -1300,7 +1301,7 @@ export function useMenuActions({
separator: actions.length > 0 separator: actions.length > 0
}) })
if (pubkey && event.pubkey !== pubkey) { if (canManageIdentity && pubkey && event.pubkey !== pubkey) {
if (isMuted) { if (isMuted) {
actions.push({ actions.push({
icon: Bell, icon: Bell,
@ -1340,7 +1341,7 @@ export function useMenuActions({
const savesGroupStartIndex = actions.length const savesGroupStartIndex = actions.length
const savesGroupNeedsSeparator = savesGroupStartIndex > 0 const savesGroupNeedsSeparator = savesGroupStartIndex > 0
if (threadWatch && pubkey) { if (canManageIdentity && threadWatch && pubkey) {
actions.push({ actions.push({
icon: Bell, icon: Bell,
label: threadFollowed ? t('Unfollow thread notifications') : t('Follow this'), label: threadFollowed ? t('Unfollow thread notifications') : t('Follow this'),
@ -1401,7 +1402,7 @@ export function useMenuActions({
}) })
} }
if (pubkey && event.pubkey === pubkey) { if (canManageIdentity && pubkey && event.pubkey === pubkey) {
actions.push({ actions.push({
icon: Pin, icon: Pin,
label: isPinnedInMyList ? t('Unpin note') : t('Pin note'), label: isPinnedInMyList ? t('Unpin note') : t('Pin note'),
@ -1410,7 +1411,7 @@ export function useMenuActions({
}, },
separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
}) })
} else if (pubkey && event.pubkey !== pubkey && bookmarksContext) { } else if (canManageIdentity && pubkey && event.pubkey !== pubkey && bookmarksContext) {
actions.push({ actions.push({
icon: Bookmark, icon: Bookmark,
label: isBookmarked ? t('Remove bookmark') : t('Bookmark'), label: isBookmarked ? t('Remove bookmark') : t('Bookmark'),

4
src/components/NotificationThreadWatchButtons/index.tsx

@ -9,12 +9,12 @@ import { useNostr } from '@/providers/NostrProvider'
export default function NotificationThreadWatchButtons({ event }: { event: Event }) { export default function NotificationThreadWatchButtons({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin, canManageIdentity } = useNostr()
const watch = useNotificationThreadWatchOptional() const watch = useNotificationThreadWatchOptional()
const [busy, setBusy] = useState<'follow' | 'mute' | null>(null) const [busy, setBusy] = useState<'follow' | 'mute' | null>(null)
// Show for your own notes too (e.g. notifications feed): you may still want follow/mute on that anchor. // Show for your own notes too (e.g. notifications feed): you may still want follow/mute on that anchor.
if (!watch || !pubkey) return null if (!watch || !pubkey || !canManageIdentity) return null
const followed = watch.isFollowedForNotifications(event) const followed = watch.isFollowedForNotifications(event)
const muted = watch.isMutedForNotifications(event) const muted = watch.isMutedForNotifications(event)

7
src/components/ProfileOptions/index.tsx

@ -63,7 +63,7 @@ export default function ProfileOptions({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const { pubkey: accountPubkey, publish, checkLogin } = useNostr() const { pubkey: accountPubkey, publish, checkLogin, canManageIdentity } = useNostr()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList() const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays() const { relaySets, favoriteRelays } = useFavoriteRelays()
@ -294,7 +294,8 @@ export default function ProfileOptions({
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
{isMuted ? ( {canManageIdentity &&
(isMuted ? (
<DropdownMenuItem <DropdownMenuItem
onClick={() => unmutePubkey(pubkey)} onClick={() => unmutePubkey(pubkey)}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
@ -319,7 +320,7 @@ export default function ProfileOptions({
{t('Mute user publicly')} {t('Mute user publicly')}
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} ))}
</DropdownMenuContent> </DropdownMenuContent>
{eventToUse && ( {eventToUse && (
<PostEditor <PostEditor

62
src/components/StoredAccountSwitchSelect.tsx

@ -1,6 +1,13 @@
import { AnonUserAvatar } from '@/components/AnonUserAvatar'
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,
createAnonAccountPointer,
isAnonAccount,
isRedundantAccountPick,
listSwitchableAccounts
} from '@/lib/account'
import { accountPubkeyToHex, 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'
@ -28,6 +35,7 @@ type Props = {
} }
const EXTENSION_SYNC_HINT_DISMISSED_PREFIX = 'extensionSyncHintDismissed:' const EXTENSION_SYNC_HINT_DISMISSED_PREFIX = 'extensionSyncHintDismissed:'
const anonAccount = createAnonAccountPointer()
function readExtensionSyncHintDismissed(pubkey: string | null): boolean { function readExtensionSyncHintDismissed(pubkey: string | null): boolean {
if (!pubkey || typeof window === 'undefined') return false if (!pubkey || typeof window === 'undefined') return false
@ -55,6 +63,7 @@ export default function StoredAccountSwitchSelect({
pubkey, pubkey,
account, account,
accounts, accounts,
isAnonSession,
switchAccount, switchAccount,
isAccountSessionHydrating, isAccountSessionHydrating,
retryNip07SignerForPreferredAccount, retryNip07SignerForPreferredAccount,
@ -67,9 +76,10 @@ export default function StoredAccountSwitchSelect({
const [extensionSyncHintDismissed, setExtensionSyncHintDismissed] = useState(false) const [extensionSyncHintDismissed, setExtensionSyncHintDismissed] = useState(false)
const sessionPubkey = useMemo(() => { const sessionPubkey = useMemo(() => {
if (isAnonSession) return null
const cur = pubkey?.trim() const cur = pubkey?.trim()
return cur ? normalizeHexPubkey(cur) : null return cur ? normalizeHexPubkey(cur) : null
}, [pubkey]) }, [pubkey, isAnonSession])
const storedAccounts = useMemo(() => listSwitchableAccounts(accounts), [accounts]) const storedAccounts = useMemo(() => listSwitchableAccounts(accounts), [accounts])
@ -135,6 +145,17 @@ export default function StoredAccountSwitchSelect({
const handlePick = useCallback( const handlePick = useCallback(
async (nextAccount: TAccountPointer) => { async (nextAccount: TAccountPointer) => {
if (isAnonAccount(nextAccount)) {
if (isAnonSession) return
setSwitchingKey(accountPointerKey(nextAccount))
try {
await switchAccount(nextAccount)
} finally {
setSwitchingKey(null)
}
return
}
const target = accountPubkeyToHex(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') {
@ -172,7 +193,7 @@ export default function StoredAccountSwitchSelect({
setSwitchingKey(null) setSwitchingKey(null)
} }
}, },
[account, switchAccount, retryNip07SignerForPreferredAccount, t, inComposer] [account, switchAccount, retryNip07SignerForPreferredAccount, t, inComposer, isAnonSession]
) )
const handleRetryExtension = useCallback(async () => { const handleRetryExtension = useCallback(async () => {
@ -199,9 +220,14 @@ export default function StoredAccountSwitchSelect({
} }
}, [sessionPubkey]) }, [sessionPubkey])
if (storedAccounts.length <= 1 || !sessionPubkey) return null const showSwitcher =
inComposer || isAnonSession || storedAccounts.length > 1 || storedAccounts.length > 0
if (!showSwitcher) return null
if (!inComposer && storedAccounts.length <= 1 && !isAnonSession) return null
const busy = isAccountSessionHydrating || switchingKey !== null const busy = isAccountSessionHydrating || switchingKey !== null
const anonActive = isAnonSession
const anonSwitching = switchingKey === accountPointerKey(anonAccount)
return ( return (
<div <div
@ -216,7 +242,7 @@ export default function StoredAccountSwitchSelect({
className className
)} )}
role="group" role="group"
aria-label={t('notificationsViewAsAccountAria')} aria-label={t('notificationsViewAsAccount')}
> >
<div <div
className={cn( className={cn(
@ -233,9 +259,35 @@ export default function StoredAccountSwitchSelect({
{t('notificationsViewAsAccount')} {t('notificationsViewAsAccount')}
</span> </span>
<div className="flex min-w-0 flex-wrap items-center gap-1.5"> <div className="flex min-w-0 flex-wrap items-center gap-1.5">
<button
type="button"
disabled={busy && !anonSwitching}
aria-pressed={anonActive}
aria-label={t('accountSwitch.selectAnon')}
title={t('accountSwitch.anonHint')}
className={cn(
'relative shrink-0 rounded-full p-0.5 transition-[box-shadow,opacity]',
'ring-2 ring-offset-2 ring-offset-background',
anonActive ? 'ring-primary' : 'ring-transparent hover:ring-muted-foreground/35',
busy && !anonSwitching && 'opacity-50'
)}
onClick={(e) => {
if (inComposer) e.stopPropagation()
void handlePick(anonAccount)
}}
>
<AnonUserAvatar size="small" />
{anonSwitching ? (
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
<Loader2 className="size-4 animate-spin text-muted-foreground" aria-hidden />
</span>
) : null}
</button>
{storedAccounts.map((act) => { {storedAccounts.map((act) => {
const pk = normalizeHexPubkey(act.pubkey) const pk = normalizeHexPubkey(act.pubkey)
const isActive = const isActive =
!isAnonSession &&
sessionPubkey != null &&
hexPubkeysEqual(pk, sessionPubkey) && hexPubkeysEqual(pk, sessionPubkey) &&
(account?.signerType === act.signerType || (account?.signerType === act.signerType ||
(account?.signerType === 'npub' && (account?.signerType === 'npub' &&

13
src/hooks/useSignGatedControl.ts

@ -2,19 +2,28 @@ import { useNostr } from '@/providers/NostrProvider'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
/** /**
* Read-only (npub) session helpers for disabling publish / social controls. * Read-only (npub) and anon session helpers for disabling publish / social controls.
*/ */
export function useSignGatedControl() { export function useSignGatedControl() {
const { canSignEvents } = useNostr() const { canSignEvents, canManageIdentity, isAnonSession } = useNostr()
const { t } = useTranslation() const { t } = useTranslation()
const readOnlyTitle = t('readOnlySession.hint') const readOnlyTitle = t('readOnlySession.hint')
const anonIdentityTitle = t('accountSwitch.anonIdentityDisabled')
return { return {
canSignEvents, canSignEvents,
/** Follow, mute, bookmarks, profile lists — requires a stable logged-in identity. */
canManageIdentity,
isAnonSession,
/** Merge into button/menu props: disabled + title when read-only. */ /** Merge into button/menu props: disabled + title when read-only. */
signControlProps: (extra?: { disabled?: boolean; title?: string }) => ({ signControlProps: (extra?: { disabled?: boolean; title?: string }) => ({
disabled: !canSignEvents || Boolean(extra?.disabled), disabled: !canSignEvents || Boolean(extra?.disabled),
title: !canSignEvents ? readOnlyTitle : extra?.title title: !canSignEvents ? readOnlyTitle : extra?.title
}),
/** Merge into follow/mute/profile controls. */
identityControlProps: (extra?: { disabled?: boolean; title?: string }) => ({
disabled: !canManageIdentity || Boolean(extra?.disabled),
title: isAnonSession ? anonIdentityTitle : !canManageIdentity ? readOnlyTitle : extra?.title
}) })
} }
} }

9
src/i18n/locales/en.ts

@ -1025,6 +1025,15 @@ export default {
'Could not sign in as this account — your extension is using a different key. Switch the key in the extension or tap “Retry extension” below.', 'Could not sign in as this account — your extension is using a different key. Switch the key in the extension or tap “Retry extension” below.',
'accountSwitch.extensionUnavailable': 'accountSwitch.extensionUnavailable':
'Could not reach the browser extension. Unlock nos2x/Alby, allow this site, then click the account again.', 'Could not reach the browser extension. Unlock nos2x/Alby, allow this site, then click the account again.',
'accountSwitch.anon': 'Anon',
'accountSwitch.selectAnon': 'Post anonymously',
'accountSwitch.anonHint':
'Each post, reply, reaction, or auth uses a fresh key. Default read/write relays only.',
'accountSwitch.anonHintShort': 'Fresh key every action · default relays',
'accountSwitch.anonCannotDelete':
'Anonymous sessions cannot delete notes (each action uses a new key).',
'accountSwitch.anonIdentityDisabled':
'Switch to a logged-in account for profile, follow, mute, and other identity features.',
'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.':

18
src/lib/account.ts

@ -1,6 +1,21 @@
import { isAnonAccount } from '@/lib/anon-session'
import { accountPubkeyToHex, hexPubkeysEqual } from '@/lib/pubkey' import { accountPubkeyToHex, hexPubkeysEqual } from '@/lib/pubkey'
import { TAccount, TAccountPointer, TSignerType } from '@/types' import { TAccount, TAccountPointer, TSignerType } from '@/types'
export { createAnonAccountPointer, isAnonAccount } from '@/lib/anon-session'
/** True when the session can sign events (includes anonymous write mode). */
export function canAccountSignEvents(account: TAccountPointer | null | undefined): boolean {
if (!account) return false
if (account.signerType === 'npub') return false
return true
}
/** True when the session has a stable identity (follow/mute/profile/lists). False for anon write mode. */
export function canManageIdentityFeatures(account: TAccountPointer | null | undefined): boolean {
return canAccountSignEvents(account) && !isAnonAccount(account)
}
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
@ -41,7 +56,8 @@ const SWITCH_SIGNER_PRIORITY: Record<TSignerType, number> = {
'browser-nsec': 1, 'browser-nsec': 1,
ncryptsec: 2, ncryptsec: 2,
bunker: 3, bunker: 3,
npub: 4 npub: 4,
anon: 99
} }
function normalizedPubkeyHex(account: TAccountPointer): string | null { function normalizedPubkeyHex(account: TAccountPointer): string | null {

42
src/lib/anon-session.ts

@ -0,0 +1,42 @@
import { NsecSigner } from '@/providers/NostrProvider/nsec.signer'
import type { TAccountPointer } from '@/types'
import { generateSecretKey } from 'nostr-tools'
/** Sentinel pubkey for the anonymous write session (not a real key). */
export const ANON_ACCOUNT_PUBKEY = '__anon__'
const ANON_SESSION_STORAGE_KEY = 'jumble-anon-session'
export function createAnonAccountPointer(): TAccountPointer {
return { pubkey: ANON_ACCOUNT_PUBKEY, signerType: 'anon' }
}
export function isAnonAccount(account: TAccountPointer | null | undefined): boolean {
return account?.signerType === 'anon'
}
export function isAnonSessionPersisted(): boolean {
if (typeof window === 'undefined') return false
try {
return sessionStorage.getItem(ANON_SESSION_STORAGE_KEY) === '1'
} catch {
return false
}
}
export function setAnonSessionPersisted(active: boolean): void {
if (typeof window === 'undefined') return
try {
if (active) sessionStorage.setItem(ANON_SESSION_STORAGE_KEY, '1')
else sessionStorage.removeItem(ANON_SESSION_STORAGE_KEY)
} catch {
// ignore quota / private browsing
}
}
/** Fresh nsec signer — new keypair on every call. */
export function createEphemeralSigner(): NsecSigner {
const signer = new NsecSigner()
signer.login(generateSecretKey())
return signer
}

139
src/providers/NostrProvider/index.tsx

@ -84,7 +84,14 @@ 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 { findStoredAccountForPointer, isSameAccount } from '@/lib/account' import { findStoredAccountForPointer, isSameAccount, canAccountSignEvents, canManageIdentityFeatures } from '@/lib/account'
import {
createAnonAccountPointer,
createEphemeralSigner,
isAnonAccount,
isAnonSessionPersisted,
setAnonSessionPersisted
} from '@/lib/anon-session'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer' import { BunkerSigner } from './bunker.signer'
@ -103,8 +110,10 @@ let nostrSessionRestoreStarted = false
function favoriteRelayUrlsForPublish( function favoriteRelayUrlsForPublish(
favoriteRelaysEvent: Event | null, favoriteRelaysEvent: Event | null,
pubkey: string | null, pubkey: string | null,
relayList: TRelayList | null | undefined relayList: TRelayList | null | undefined,
account: TAccountPointer | null
): string[] { ): string[] {
if (isAnonAccount(account)) return [...DEFAULT_FAVORITE_RELAYS]
const urlsFromEvent = (): string[] => { const urlsFromEvent = (): string[] => {
const urls: string[] = [] const urls: string[] = []
if (!favoriteRelaysEvent) return urls if (!favoriteRelaysEvent) return urls
@ -206,6 +215,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return await loginByNostrLoginHash() return await loginByNostrLoginHash()
} }
if (isAnonSessionPersisted()) {
loginAnon()
return
}
const accounts = storage.getAccounts() const accounts = storage.getAccounts()
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
if (!act) return if (!act) return
@ -263,6 +277,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return undefined return undefined
} }
if (isAnonAccount(account)) {
setIsAccountSessionHydrating(false)
lastNetworkHydrateAccountPubkeyRef.current = null
return undefined
}
const userForcedAccountNetworkHydrate = forceNextAccountNetworkHydrateRef.current const userForcedAccountNetworkHydrate = forceNextAccountNetworkHydrateRef.current
if (userForcedAccountNetworkHydrate) { if (userForcedAccountNetworkHydrate) {
forceNextAccountNetworkHydrateRef.current = false forceNextAccountNetworkHydrateRef.current = false
@ -1248,15 +1268,38 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setSigner(npubSigner) setSigner(npubSigner)
} }
const loginAnon = (): null => {
setAnonSessionPersisted(true)
clearSessionUiForAccountChange()
accountHydrationGenerationRef.current += 1
lastNetworkHydrateAccountPubkeyRef.current = null
const pointer = createAnonAccountPointer()
setAccount(pointer)
setSigner(null)
setNsec(null)
setNcryptsec(null)
accountForReplaceablesSyncRef.current = pointer
client.setSigner(undefined, 'anon')
client.pubkey = undefined
return null
}
const switchAccount = async (act: TAccountPointer | null): Promise<string | null> => { const switchAccount = async (act: TAccountPointer | null): Promise<string | null> => {
intentionalNip07ReadOnlyPubkeyRef.current = null intentionalNip07ReadOnlyPubkeyRef.current = null
if (!act) { if (!act) {
setAnonSessionPersisted(false)
storage.switchAccount(null) storage.switchAccount(null)
setAccount(null) setAccount(null)
setSigner(null) setSigner(null)
window.dispatchEvent(new CustomEvent(APP_RESET_TO_LANDING_EVENT)) window.dispatchEvent(new CustomEvent(APP_RESET_TO_LANDING_EVENT))
return null return null
} }
if (isAnonAccount(act)) {
loginAnon()
return null
}
setAnonSessionPersisted(false)
const result = await loginWithAccountPointer(act, { userInitiatedSwitch: true }) const result = await loginWithAccountPointer(act, { userInitiatedSwitch: true })
// 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.
@ -1489,6 +1532,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
act: TAccountPointer, act: TAccountPointer,
options?: { userInitiatedSwitch?: boolean } options?: { userInitiatedSwitch?: boolean }
): Promise<string | null> => { ): Promise<string | null> => {
if (isAnonAccount(act)) {
loginAnon()
return null
}
const fallbackToReadOnlyNpub = (pubkey: string, reason?: unknown): string => { const fallbackToReadOnlyNpub = (pubkey: string, reason?: unknown): string => {
const pk = const pk =
accountPubkeyToHex(pubkey) ?? accountPubkeyToHex(pubkey) ??
@ -1870,6 +1917,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
normalizeOpts?: { addClientTag?: boolean } normalizeOpts?: { addClientTag?: boolean }
) => { ) => {
const normalizedDraft = normalizeDraftEventTags(draftEvent, normalizeOpts) const normalizedDraft = normalizeDraftEventTags(draftEvent, normalizeOpts)
if (isAnonAccount(account)) {
const ephemeral = createEphemeralSigner()
const event = await ephemeral.signEvent(normalizedDraft)
if (!validateEvent(event)) {
throw new Error('Event validation failed - invalid signature or format.')
}
return event as VerifiedEvent
}
// Add timeout to prevent hanging // Add timeout to prevent hanging
const signEventWithTimeout = new Promise((resolve, reject) => { const signEventWithTimeout = new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -1925,12 +1982,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
draftEvent: TDraftEvent, draftEvent: TDraftEvent,
{ minPow = 0, ...options }: TPublishOptions = {} { minPow = 0, ...options }: TPublishOptions = {}
) => { ) => {
if (!account || !signer || account.signerType === 'npub') { if (!account || account.signerType === 'npub') {
throw new LoginRequiredError()
}
if (!isAnonAccount(account) && !signer) {
throw new LoginRequiredError() throw new LoginRequiredError()
} }
const accountPk = accountPubkeyToHex(account.pubkey) const anonSigner = isAnonAccount(account) ? createEphemeralSigner() : null
const activeSigner = anonSigner ?? signer
if (!activeSigner) {
throw new LoginRequiredError()
}
const accountPk = isAnonAccount(account) ? null : accountPubkeyToHex(account.pubkey)
if (!isAnonAccount(account)) {
await assertSignerMatchesAccountForPublish() await assertSignerMatchesAccountForPublish()
}
const normalizeOpts = { addClientTag: options.addClientTag } const normalizeOpts = { addClientTag: options.addClientTag }
const draft = normalizeDraftEventTags(draftEvent, normalizeOpts) const draft = normalizeDraftEventTags(draftEvent, normalizeOpts)
@ -1944,12 +2012,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
) )
} }
const publishPubkey = anonSigner ? await anonSigner.getPublicKey() : account.pubkey
const unsignedTemplate = { const unsignedTemplate = {
kind: draft.kind, kind: draft.kind,
content: draft.content, content: draft.content,
tags: draft.tags, tags: draft.tags,
created_at: draft.created_at, created_at: draft.created_at,
pubkey: account.pubkey pubkey: publishPubkey
} }
if (!validateEvent(unsignedTemplate)) { if (!validateEvent(unsignedTemplate)) {
throw new Error(t('Invalid event fields')) throw new Error(t('Invalid event fields'))
@ -1957,13 +2026,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const id = getEventHash(unsignedTemplate) const id = getEventHash(unsignedTemplate)
event = { ...unsignedTemplate, id, sig: '' } event = { ...unsignedTemplate, id, sig: '' }
} else if (minPow > 0) { } else if (minPow > 0) {
const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow) const publishPubkey = anonSigner ? await anonSigner.getPublicKey() : account.pubkey
event = await signEvent(unsignedEvent, normalizeOpts) const unsignedEvent = await minePow({ ...draft, pubkey: publishPubkey }, minPow)
const normalizedUnsigned = normalizeDraftEventTags(unsignedEvent, normalizeOpts)
event = await activeSigner.signEvent(normalizedUnsigned)
if (!validateEvent(event)) {
throw new Error('Event validation failed - invalid signature or format.')
}
} else { } else {
event = await signEvent(draft, normalizeOpts) const normalizedDraft = normalizeDraftEventTags(draft, normalizeOpts)
event = await activeSigner.signEvent(normalizedDraft)
if (!validateEvent(event)) {
throw new Error('Event validation failed - invalid signature or format.')
}
} }
if ( if (
!isAnonAccount(account) &&
event.kind !== kinds.Application && event.kind !== kinds.Application &&
accountPk && accountPk &&
!hexPubkeysEqual(event.pubkey, accountPk) !hexPubkeysEqual(event.pubkey, accountPk)
@ -1976,7 +2055,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
let publishRelayCandidates: string[] = [] let publishRelayCandidates: string[] = []
try { try {
logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) }) logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) })
const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey, relayList) const favoriteRelayUrls = favoriteRelayUrlsForPublish(
favoriteRelaysEvent,
account.pubkey,
relayList,
account
)
publishRelayCandidates = await client.determineTargetRelays(event, { publishRelayCandidates = await client.determineTargetRelays(event, {
...options, ...options,
favoriteRelayUrls, favoriteRelayUrls,
@ -2105,10 +2189,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const attemptDelete = async (targetEvent: Event) => { const attemptDelete = async (targetEvent: Event) => {
if (!signer || account?.signerType === 'npub') { if (!account || account.signerType === 'npub') {
return return
} }
if (account?.pubkey !== targetEvent.pubkey) { if (isAnonAccount(account)) {
throw new Error(t('accountSwitch.anonCannotDelete'))
}
if (!signer) {
return
}
if (account.pubkey !== targetEvent.pubkey) {
throw new Error(t('You can only delete your own notes')) throw new Error(t('You can only delete your own notes'))
} }
@ -2117,7 +2207,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
// Privacy: Only use user's own relays, never connect to "seen on" relays // Privacy: Only use user's own relays, never connect to "seen on" relays
const favUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account?.pubkey ?? null, relayList) const favUrls = favoriteRelayUrlsForPublish(
favoriteRelaysEvent,
account?.pubkey ?? null,
relayList,
account
)
const relays = await client.determineTargetRelays(targetEvent, { const relays = await client.determineTargetRelays(targetEvent, {
favoriteRelayUrls: favUrls, favoriteRelayUrls: favUrls,
blockedRelayUrls: blockedRelayUrlsFromEvent(blockedRelaysEvent) blockedRelayUrls: blockedRelayUrlsFromEvent(blockedRelaysEvent)
@ -2152,10 +2247,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const nip04Encrypt = async (pubkey: string, plainText: string) => { const nip04Encrypt = async (pubkey: string, plainText: string) => {
if (isAnonAccount(account)) {
return createEphemeralSigner().nip04Encrypt(pubkey, plainText)
}
return signer?.nip04Encrypt(pubkey, plainText) ?? '' return signer?.nip04Encrypt(pubkey, plainText) ?? ''
} }
const nip04Decrypt = async (pubkey: string, cipherText: string) => { const nip04Decrypt = async (pubkey: string, cipherText: string) => {
if (isAnonAccount(account)) {
try {
return (await createEphemeralSigner().nip04Decrypt(pubkey, cipherText)) ?? ''
} catch {
return ''
}
}
if (!signer) return '' if (!signer) return ''
try { try {
return (await signer.nip04Decrypt(pubkey, cipherText)) ?? '' return (await signer.nip04Decrypt(pubkey, cipherText)) ?? ''
@ -2172,6 +2277,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
return return
} }
if (isAnonAccount(account)) {
if (cb) return await cb()
return
}
if (!signer) { if (!signer) {
setOpenLoginDialog(true) setOpenLoginDialog(true)
return return
@ -2404,7 +2513,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
isInitialized, isInitialized,
isAccountSessionHydrating, isAccountSessionHydrating,
isNip07LoginInFlight, isNip07LoginInFlight,
pubkey: account?.pubkey ?? null, pubkey: isAnonAccount(account) ? null : (account?.pubkey ?? null),
profile, profile,
profileEvent, profileEvent,
relayList, relayList,
@ -2420,7 +2529,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
rssFeedListEvent, rssFeedListEvent,
account, account,
accounts, accounts,
canSignEvents: account != null && account.signerType !== 'npub', canSignEvents: canAccountSignEvents(account),
isAnonSession: isAnonAccount(account),
canManageIdentity: canManageIdentityFeatures(account),
nsec, nsec,
ncryptsec, ncryptsec,
switchAccount: switchAccountStable, switchAccount: switchAccountStable,

4
src/providers/nostr-context.tsx

@ -37,6 +37,10 @@ export type TNostrContext = {
ncryptsec: string | null ncryptsec: string | null
/** True when the session can sign (not read-only npub fallback). */ /** True when the session can sign (not read-only npub fallback). */
canSignEvents: boolean canSignEvents: boolean
/** Anonymous write session: fresh key per publish/sign/auth. */
isAnonSession: boolean
/** Stable identity features (profile, follow, mute, lists). False in anon write mode. */
canManageIdentity: boolean
/** Returns the new session pubkey on success, or `null` if logout / switch failed. */ /** Returns the new session pubkey on success, or `null` if logout / switch failed. */
switchAccount: (account: TAccountPointer | null) => Promise<string | null> switchAccount: (account: TAccountPointer | null) => Promise<string | null>
/** View an account read-only (notifications, relays) without matching the browser extension. */ /** View an account read-only (notifications, relays) without matching the browser extension. */

10
src/services/client.service.ts

@ -35,6 +35,7 @@ import {
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { createEphemeralSigner } from '@/lib/anon-session'
import { getCacheRelayUrls } from '@/lib/private-relays' import { getCacheRelayUrls } from '@/lib/private-relays'
import { import {
collectReadInboxUrlsFromRelayList, collectReadInboxUrlsFromRelayList,
@ -690,7 +691,7 @@ class ClientService extends EventTarget {
* is still signing; the batch then finishes and never refetches. Other relays stay on reactive * is still signing; the batch then finishes and never refetches. Other relays stay on reactive
* `relay.auth()` after `auth-required` to avoid double-sign races with the wider pool. * `relay.auth()` after `auth-required` to avoid double-sign races with the wider pool.
*/ */
if (signer && signerType !== 'npub') { if (signer && signerType !== 'npub' && signerType !== 'anon') {
this.pool.automaticallyAuth = (relayURL: string) => { this.pool.automaticallyAuth = (relayURL: string) => {
const n = normalizeUrl(relayURL) || relayURL const n = normalizeUrl(relayURL) || relayURL
if (!READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)) return null if (!READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)) return null
@ -701,6 +702,13 @@ class ClientService extends EventTarget {
return evt as VerifiedEvent return evt as VerifiedEvent
} }
} }
} else if (signerType === 'anon') {
this.pool.automaticallyAuth = () => {
return async (event: EventTemplate) => {
const ephemeral = createEphemeralSigner()
return (await ephemeral.signEvent(event)) as VerifiedEvent
}
}
} else { } else {
this.pool.automaticallyAuth = undefined this.pool.automaticallyAuth = undefined
} }

10
src/types/index.d.ts vendored

@ -165,7 +165,15 @@ export interface ISigner {
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string> nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
} }
export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' | 'npub' export type TSignerType =
| 'nsec'
| 'nip-07'
| 'bunker'
| 'browser-nsec'
| 'ncryptsec'
| 'npub'
/** Ephemeral write session: fresh key per sign/publish/auth action. */
| 'anon'
export type TAccount = { export type TAccount = {
pubkey: string pubkey: string

Loading…
Cancel
Save