diff --git a/src/components/NcryptsecPasswordPrompt/index.tsx b/src/components/NcryptsecPasswordPrompt/index.tsx new file mode 100644 index 00000000..a6b24acf --- /dev/null +++ b/src/components/NcryptsecPasswordPrompt/index.tsx @@ -0,0 +1,98 @@ +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type Props = { + open: boolean + onResult: (password: string | null) => void +} + +export default function NcryptsecPasswordPrompt({ open, onResult }: Props) { + const { t } = useTranslation() + const { isSmallScreen } = useScreenSize() + const [password, setPassword] = useState('') + + useEffect(() => { + if (open) setPassword('') + }, [open]) + + const title = t('Enter the password to decrypt your ncryptsec') + + const submit = () => { + const trimmed = password.trim() + onResult(trimmed.length > 0 ? trimmed : null) + } + + const cancel = () => { + onResult(null) + } + + const form = ( +
{ + e.preventDefault() + submit() + }} + > +
+ + setPassword(e.target.value)} + /> +
+
+ + +
+
+ ) + + if (isSmallScreen) { + return ( + { + if (!next) cancel() + }} + > + + + {title} + {title} + +
{form}
+
+
+ ) + } + + return ( + { + if (!next) cancel() + }} + > + + + {title} + {title} + + {form} + + + ) +} diff --git a/src/components/SaveRelayDropdownMenu/index.tsx b/src/components/SaveRelayDropdownMenu/index.tsx index 8a7fd0fa..d8403aeb 100644 --- a/src/components/SaveRelayDropdownMenu/index.tsx +++ b/src/components/SaveRelayDropdownMenu/index.tsx @@ -1,7 +1,15 @@ import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' import { Drawer, DrawerContent, + DrawerDescription, DrawerHeader, DrawerOverlay, DrawerTitle @@ -14,6 +22,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' import { normalizeUrl } from '@/lib/url' @@ -22,7 +32,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { TRelaySet } from '@/types' import { Ban, Check, FolderPlus, Plus, Star } from 'lucide-react' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import DrawerMenuItem from '../DrawerMenuItem' import logger from '@/lib/logger' @@ -207,32 +217,133 @@ function SaveToNewSet({ urls }: { urls: string[] }) { const { isSmallScreen } = useScreenSize() const { pubkey, startLogin } = useNostr() const { createRelaySet } = useFavoriteRelays() + const [namePromptOpen, setNamePromptOpen] = useState(false) - const handleSave = () => { + const openNamePrompt = () => { if (!pubkey) { startLogin() return } - const newSetName = prompt(t('Enter a name for the new relay set')) - if (newSetName) { - createRelaySet(newSetName, urls) + setNamePromptOpen(true) + } + + const onNameResult = (name: string | null) => { + setNamePromptOpen(false) + if (name) { + createRelaySet(name, urls) } } + return ( + <> + {isSmallScreen ? ( + + + {t('Save to a new relay set')} + + ) : ( + + + {t('Save to a new relay set')} + + )} + + + ) +} + +function RelaySetNamePrompt({ + open, + title, + onResult +}: { + open: boolean + title: string + onResult: (name: string | null) => void +}) { + const { t } = useTranslation() + const { isSmallScreen } = useScreenSize() + const [value, setValue] = useState('') + + useEffect(() => { + if (open) setValue('') + }, [open]) + + const submit = () => { + const trimmed = value.trim() + onResult(trimmed.length > 0 ? trimmed : null) + } + + const cancel = () => { + onResult(null) + } + + const form = ( +
{ + e.preventDefault() + submit() + }} + > +
+ + setValue(e.target.value)} + /> +
+
+ + +
+
+ ) + if (isSmallScreen) { return ( - - - {t('Save to a new relay set')} - + { + if (!next) cancel() + }} + > + + + {title} + {title} + +
{form}
+
+
) } return ( - - - {t('Save to a new relay set')} - + { + if (!next) cancel() + }} + > + + + {title} + {title} + + {form} + + ) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 2d45ade7..704aa32a 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1,4 +1,5 @@ import LoginDialog from '@/components/LoginDialog' +import NcryptsecPasswordPrompt from '@/components/NcryptsecPasswordPrompt' import { ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS, DEFAULT_FAVORITE_RELAYS, @@ -95,6 +96,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [ncryptsec, setNcryptsec] = useState(null) const [signer, setSigner] = useState(null) const [openLoginDialog, setOpenLoginDialog] = useState(false) + const [ncryptsecPasswordOpen, setNcryptsecPasswordOpen] = useState(false) + const ncryptsecPasswordResolveRef = useRef<((value: string | null) => void) | null>(null) const [profile, setProfile] = useState(null) // Cleanup on page unload to prevent extension UI issues @@ -812,6 +815,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { await loginWithAccountPointer(act) } + const finishNcryptsecPasswordPrompt = useCallback((password: string | null) => { + const resolve = ncryptsecPasswordResolveRef.current + if (!resolve) return + ncryptsecPasswordResolveRef.current = null + setNcryptsecPasswordOpen(false) + resolve(password) + }, []) + + const askNcryptsecPassword = useCallback((): Promise => { + return new Promise((resolve) => { + const prev = ncryptsecPasswordResolveRef.current + if (prev) prev(null) + ncryptsecPasswordResolveRef.current = resolve + setNcryptsecPasswordOpen(true) + }) + }, []) + const nsecLogin = async (nsecOrHex: string, password?: string, needSetup?: boolean) => { const nsecSigner = new NsecSigner() let privkey: Uint8Array @@ -840,11 +860,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } const ncryptsecLogin = async (ncryptsec: string) => { - const password = prompt(t('Enter the password to decrypt your ncryptsec')) + const password = await askNcryptsecPassword() if (!password) { throw new Error('Password is required') } - const privkey = nip49.decrypt(ncryptsec, password) + let privkey: Uint8Array + try { + privkey = nip49.decrypt(ncryptsec, password) + } catch (e) { + toast.error(t('Login failed') + ': ' + (e as Error).message) + throw e + } const browserNsecSigner = new NsecSigner() const pubkey = browserNsecSigner.login(privkey) return login(browserNsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec }) @@ -922,11 +948,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } else if (account.signerType === 'ncryptsec') { if (account.ncryptsec) { - const password = prompt(t('Enter the password to decrypt your ncryptsec')) + const password = await askNcryptsecPassword() if (!password) { return null } - const privkey = nip49.decrypt(account.ncryptsec, password) + let privkey: Uint8Array + try { + privkey = nip49.decrypt(account.ncryptsec, password) + } catch (e) { + toast.error(t('Login failed') + ': ' + (e as Error).message) + return null + } const browserNsecSigner = new NsecSigner() browserNsecSigner.login(privkey) return login(browserNsecSigner, account) @@ -1376,6 +1408,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { > {children} + ) }