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 @@ @@ -1,3 +1,4 @@
import { AnonUserAvatar } from '@/components/AnonUserAvatar'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username'
import {
@ -7,6 +8,8 @@ import { @@ -7,6 +8,8 @@ import {
} from '@/components/ui/dropdown-menu'
import {
accountPointerKey,
createAnonAccountPointer,
isAnonAccount,
isRedundantAccountPick,
isSameAccountPubkey,
listSwitchableAccounts
@ -19,19 +22,32 @@ import { Check } from 'lucide-react' @@ -19,19 +22,32 @@ import { Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const anonAccount = createAnonAccountPointer()
export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?: () => void }) {
const { t } = useTranslation()
const {
accounts,
account,
isAnonSession,
switchAccount,
retryNip07SignerForPreferredAccount
} = useNostr()
const rows = listSwitchableAccounts(accounts)
if (rows.length <= 1) return null
if (rows.length === 0 && !isAnonSession) return null
const handleSwitch = async (act: TAccountPointer) => {
if (isAnonAccount(act)) {
if (isAnonSession) {
onAfterSwitch?.()
return
}
await switchAccount(act)
onAfterSwitch?.()
return
}
if (isRedundantAccountPick(act, account)) {
if (account?.signerType === 'npub' && act.signerType === 'nip-07') {
const switched = await switchAccount(act)
@ -62,8 +78,19 @@ export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?: @@ -62,8 +78,19 @@ export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?:
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
{t('notificationsViewAsAccount')}
</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) => {
const active =
!isAnonSession &&
account != null &&
isSameAccountPubkey(act, account) &&
(account.signerType === act.signerType ||

25
src/components/AnonUserAvatar.tsx

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

4
src/components/FollowButton/index.tsx

@ -23,7 +23,7 @@ import { toast } from 'sonner' @@ -23,7 +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 { canManageIdentity, signControlProps } = useSignGatedControl()
const followList = useFollowListOptional()
const { mutePubkeySet, unmutePubkey } = useMuteList()
const [updating, setUpdating] = useState(false)
@ -33,7 +33,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) { @@ -33,7 +33,7 @@ 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 || !canSignEvents || (pubkey && pubkey === accountPubkey)) {
if (!followList || !accountPubkey || !canManageIdentity || (pubkey && pubkey === accountPubkey)) {
return null
}

61
src/components/HelpAndAccountMenu.tsx

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

4
src/components/MuteButton/index.tsx

@ -21,13 +21,13 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -21,13 +21,13 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const { signControlProps } = useSignGatedControl()
const { signControlProps, canManageIdentity } = useSignGatedControl()
const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
useMuteList()
const [updating, setUpdating] = useState(false)
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) => {
e.stopPropagation()

11
src/components/NoteOptions/useMenuActions.tsx

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

4
src/components/NotificationThreadWatchButtons/index.tsx

@ -9,12 +9,12 @@ import { useNostr } from '@/providers/NostrProvider' @@ -9,12 +9,12 @@ import { useNostr } from '@/providers/NostrProvider'
export default function NotificationThreadWatchButtons({ event }: { event: Event }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { pubkey, checkLogin, canManageIdentity } = useNostr()
const watch = useNotificationThreadWatchOptional()
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.
if (!watch || !pubkey) return null
if (!watch || !pubkey || !canManageIdentity) return null
const followed = watch.isFollowedForNotifications(event)
const muted = watch.isMutedForNotifications(event)

7
src/components/ProfileOptions/index.tsx

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

62
src/components/StoredAccountSwitchSelect.tsx

@ -1,6 +1,13 @@ @@ -1,6 +1,13 @@
import { AnonUserAvatar } from '@/components/AnonUserAvatar'
import { SimpleUserAvatar } from '@/components/UserAvatar'
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 { cn } from '@/lib/utils'
import { Nip07Signer } from '@/providers/NostrProvider/nip-07.signer'
@ -28,6 +35,7 @@ type Props = { @@ -28,6 +35,7 @@ type Props = {
}
const EXTENSION_SYNC_HINT_DISMISSED_PREFIX = 'extensionSyncHintDismissed:'
const anonAccount = createAnonAccountPointer()
function readExtensionSyncHintDismissed(pubkey: string | null): boolean {
if (!pubkey || typeof window === 'undefined') return false
@ -55,6 +63,7 @@ export default function StoredAccountSwitchSelect({ @@ -55,6 +63,7 @@ export default function StoredAccountSwitchSelect({
pubkey,
account,
accounts,
isAnonSession,
switchAccount,
isAccountSessionHydrating,
retryNip07SignerForPreferredAccount,
@ -67,9 +76,10 @@ export default function StoredAccountSwitchSelect({ @@ -67,9 +76,10 @@ export default function StoredAccountSwitchSelect({
const [extensionSyncHintDismissed, setExtensionSyncHintDismissed] = useState(false)
const sessionPubkey = useMemo(() => {
if (isAnonSession) return null
const cur = pubkey?.trim()
return cur ? normalizeHexPubkey(cur) : null
}, [pubkey])
}, [pubkey, isAnonSession])
const storedAccounts = useMemo(() => listSwitchableAccounts(accounts), [accounts])
@ -135,6 +145,17 @@ export default function StoredAccountSwitchSelect({ @@ -135,6 +145,17 @@ export default function StoredAccountSwitchSelect({
const handlePick = useCallback(
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)
if (isRedundantAccountPick(nextAccount, account)) {
if (account?.signerType === 'npub' && nextAccount.signerType === 'nip-07') {
@ -172,7 +193,7 @@ export default function StoredAccountSwitchSelect({ @@ -172,7 +193,7 @@ export default function StoredAccountSwitchSelect({
setSwitchingKey(null)
}
},
[account, switchAccount, retryNip07SignerForPreferredAccount, t, inComposer]
[account, switchAccount, retryNip07SignerForPreferredAccount, t, inComposer, isAnonSession]
)
const handleRetryExtension = useCallback(async () => {
@ -199,9 +220,14 @@ export default function StoredAccountSwitchSelect({ @@ -199,9 +220,14 @@ export default function StoredAccountSwitchSelect({
}
}, [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 anonActive = isAnonSession
const anonSwitching = switchingKey === accountPointerKey(anonAccount)
return (
<div
@ -216,7 +242,7 @@ export default function StoredAccountSwitchSelect({ @@ -216,7 +242,7 @@ export default function StoredAccountSwitchSelect({
className
)}
role="group"
aria-label={t('notificationsViewAsAccountAria')}
aria-label={t('notificationsViewAsAccount')}
>
<div
className={cn(
@ -233,9 +259,35 @@ export default function StoredAccountSwitchSelect({ @@ -233,9 +259,35 @@ export default function StoredAccountSwitchSelect({
{t('notificationsViewAsAccount')}
</span>
<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) => {
const pk = normalizeHexPubkey(act.pubkey)
const isActive =
!isAnonSession &&
sessionPubkey != null &&
hexPubkeysEqual(pk, sessionPubkey) &&
(account?.signerType === act.signerType ||
(account?.signerType === 'npub' &&

13
src/hooks/useSignGatedControl.ts

@ -2,19 +2,28 @@ import { useNostr } from '@/providers/NostrProvider' @@ -2,19 +2,28 @@ import { useNostr } from '@/providers/NostrProvider'
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() {
const { canSignEvents } = useNostr()
const { canSignEvents, canManageIdentity, isAnonSession } = useNostr()
const { t } = useTranslation()
const readOnlyTitle = t('readOnlySession.hint')
const anonIdentityTitle = t('accountSwitch.anonIdentityDisabled')
return {
canSignEvents,
/** Follow, mute, bookmarks, profile lists — requires a stable logged-in identity. */
canManageIdentity,
isAnonSession,
/** 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
}),
/** 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 { @@ -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.',
'accountSwitch.extensionUnavailable':
'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}}',
'Hide untrusted {type}': 'Hide untrusted {{type}}',
'Currently hiding {type} from untrusted users.':

18
src/lib/account.ts

@ -1,6 +1,21 @@ @@ -1,6 +1,21 @@
import { isAnonAccount } from '@/lib/anon-session'
import { accountPubkeyToHex, hexPubkeysEqual } from '@/lib/pubkey'
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) {
if (!a || !b) return false
if (a.signerType !== b.signerType) return false
@ -41,7 +56,8 @@ const SWITCH_SIGNER_PRIORITY: Record<TSignerType, number> = { @@ -41,7 +56,8 @@ const SWITCH_SIGNER_PRIORITY: Record<TSignerType, number> = {
'browser-nsec': 1,
ncryptsec: 2,
bunker: 3,
npub: 4
npub: 4,
anon: 99
}
function normalizedPubkeyHex(account: TAccountPointer): string | null {

42
src/lib/anon-session.ts

@ -0,0 +1,42 @@ @@ -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' @@ -84,7 +84,14 @@ 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 { 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 { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer'
@ -103,8 +110,10 @@ let nostrSessionRestoreStarted = false @@ -103,8 +110,10 @@ let nostrSessionRestoreStarted = false
function favoriteRelayUrlsForPublish(
favoriteRelaysEvent: Event | null,
pubkey: string | null,
relayList: TRelayList | null | undefined
relayList: TRelayList | null | undefined,
account: TAccountPointer | null
): string[] {
if (isAnonAccount(account)) return [...DEFAULT_FAVORITE_RELAYS]
const urlsFromEvent = (): string[] => {
const urls: string[] = []
if (!favoriteRelaysEvent) return urls
@ -206,6 +215,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -206,6 +215,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return await loginByNostrLoginHash()
}
if (isAnonSessionPersisted()) {
loginAnon()
return
}
const accounts = storage.getAccounts()
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
if (!act) return
@ -263,6 +277,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -263,6 +277,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return undefined
}
if (isAnonAccount(account)) {
setIsAccountSessionHydrating(false)
lastNetworkHydrateAccountPubkeyRef.current = null
return undefined
}
const userForcedAccountNetworkHydrate = forceNextAccountNetworkHydrateRef.current
if (userForcedAccountNetworkHydrate) {
forceNextAccountNetworkHydrateRef.current = false
@ -1248,15 +1268,38 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1248,15 +1268,38 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
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> => {
intentionalNip07ReadOnlyPubkeyRef.current = null
if (!act) {
setAnonSessionPersisted(false)
storage.switchAccount(null)
setAccount(null)
setSigner(null)
window.dispatchEvent(new CustomEvent(APP_RESET_TO_LANDING_EVENT))
return null
}
if (isAnonAccount(act)) {
loginAnon()
return null
}
setAnonSessionPersisted(false)
const result = await loginWithAccountPointer(act, { userInitiatedSwitch: true })
// 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.
@ -1489,6 +1532,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1489,6 +1532,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
act: TAccountPointer,
options?: { userInitiatedSwitch?: boolean }
): Promise<string | null> => {
if (isAnonAccount(act)) {
loginAnon()
return null
}
const fallbackToReadOnlyNpub = (pubkey: string, reason?: unknown): string => {
const pk =
accountPubkeyToHex(pubkey) ??
@ -1870,6 +1917,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1870,6 +1917,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
normalizeOpts?: { addClientTag?: boolean }
) => {
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
const signEventWithTimeout = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
@ -1925,12 +1982,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1925,12 +1982,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
draftEvent: TDraftEvent,
{ 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()
}
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()
}
const normalizeOpts = { addClientTag: options.addClientTag }
const draft = normalizeDraftEventTags(draftEvent, normalizeOpts)
@ -1944,12 +2012,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1944,12 +2012,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
})
)
}
const publishPubkey = anonSigner ? await anonSigner.getPublicKey() : account.pubkey
const unsignedTemplate = {
kind: draft.kind,
content: draft.content,
tags: draft.tags,
created_at: draft.created_at,
pubkey: account.pubkey
pubkey: publishPubkey
}
if (!validateEvent(unsignedTemplate)) {
throw new Error(t('Invalid event fields'))
@ -1957,13 +2026,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1957,13 +2026,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const id = getEventHash(unsignedTemplate)
event = { ...unsignedTemplate, id, sig: '' }
} else if (minPow > 0) {
const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow)
event = await signEvent(unsignedEvent, normalizeOpts)
const publishPubkey = anonSigner ? await anonSigner.getPublicKey() : account.pubkey
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 {
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 (
!isAnonAccount(account) &&
event.kind !== kinds.Application &&
accountPk &&
!hexPubkeysEqual(event.pubkey, accountPk)
@ -1976,7 +2055,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1976,7 +2055,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
let publishRelayCandidates: string[] = []
try {
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, {
...options,
favoriteRelayUrls,
@ -2105,10 +2189,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -2105,10 +2189,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const attemptDelete = async (targetEvent: Event) => {
if (!signer || account?.signerType === 'npub') {
if (!account || account.signerType === 'npub') {
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'))
}
@ -2117,7 +2207,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -2117,7 +2207,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
client.interruptBackgroundQueries()
// 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, {
favoriteRelayUrls: favUrls,
blockedRelayUrls: blockedRelayUrlsFromEvent(blockedRelaysEvent)
@ -2152,10 +2247,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -2152,10 +2247,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const nip04Encrypt = async (pubkey: string, plainText: string) => {
if (isAnonAccount(account)) {
return createEphemeralSigner().nip04Encrypt(pubkey, plainText)
}
return signer?.nip04Encrypt(pubkey, plainText) ?? ''
}
const nip04Decrypt = async (pubkey: string, cipherText: string) => {
if (isAnonAccount(account)) {
try {
return (await createEphemeralSigner().nip04Decrypt(pubkey, cipherText)) ?? ''
} catch {
return ''
}
}
if (!signer) return ''
try {
return (await signer.nip04Decrypt(pubkey, cipherText)) ?? ''
@ -2172,6 +2277,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -2172,6 +2277,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
return
}
if (isAnonAccount(account)) {
if (cb) return await cb()
return
}
if (!signer) {
setOpenLoginDialog(true)
return
@ -2404,7 +2513,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -2404,7 +2513,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
isInitialized,
isAccountSessionHydrating,
isNip07LoginInFlight,
pubkey: account?.pubkey ?? null,
pubkey: isAnonAccount(account) ? null : (account?.pubkey ?? null),
profile,
profileEvent,
relayList,
@ -2420,7 +2529,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -2420,7 +2529,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
rssFeedListEvent,
account,
accounts,
canSignEvents: account != null && account.signerType !== 'npub',
canSignEvents: canAccountSignEvents(account),
isAnonSession: isAnonAccount(account),
canManageIdentity: canManageIdentityFeatures(account),
nsec,
ncryptsec,
switchAccount: switchAccountStable,

4
src/providers/nostr-context.tsx

@ -37,6 +37,10 @@ export type TNostrContext = { @@ -37,6 +37,10 @@ export type TNostrContext = {
ncryptsec: string | null
/** True when the session can sign (not read-only npub fallback). */
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. */
switchAccount: (account: TAccountPointer | null) => Promise<string | null>
/** View an account read-only (notifications, relays) without matching the browser extension. */

10
src/services/client.service.ts

@ -35,6 +35,7 @@ import { @@ -35,6 +35,7 @@ import {
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { createEphemeralSigner } from '@/lib/anon-session'
import { getCacheRelayUrls } from '@/lib/private-relays'
import {
collectReadInboxUrlsFromRelayList,
@ -690,7 +691,7 @@ class ClientService extends EventTarget { @@ -690,7 +691,7 @@ class ClientService extends EventTarget {
* 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.
*/
if (signer && signerType !== 'npub') {
if (signer && signerType !== 'npub' && signerType !== 'anon') {
this.pool.automaticallyAuth = (relayURL: string) => {
const n = normalizeUrl(relayURL) || relayURL
if (!READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)) return null
@ -701,6 +702,13 @@ class ClientService extends EventTarget { @@ -701,6 +702,13 @@ class ClientService extends EventTarget {
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 {
this.pool.automaticallyAuth = undefined
}

10
src/types/index.d.ts vendored

@ -165,7 +165,15 @@ export interface ISigner { @@ -165,7 +165,15 @@ export interface ISigner {
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 = {
pubkey: string

Loading…
Cancel
Save