This commit is contained in:
codytseng
2025-12-23 15:36:41 +08:00
parent f8081b86bf
commit b51bc8ac6d
11 changed files with 295 additions and 152 deletions

View File

@@ -1,85 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, RefreshCcw } from 'lucide-react'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function GenerateNewAccount({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [nsec, setNsec] = useState(generateNsec())
const [copied, setCopied] = useState(false)
const [password, setPassword] = useState('')
const handleLogin = () => {
nsecLogin(nsec, password, true).then(() => onLoginSuccess())
}
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
handleLogin()
}}
>
<div className="text-orange-400">
{t(
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.'
)}
</div>
<div className="grid gap-2">
<Label>nsec</Label>
<div className="flex gap-2">
<Input value={nsec} />
<Button type="button" variant="secondary" onClick={() => setNsec(generateNsec())}>
<RefreshCcw />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="password-input">{t('password')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('optional: encrypt nsec')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
{t('Back')}
</Button>
<Button className="flex-1" type="submit">
{t('Login')}
</Button>
</div>
</form>
)
}
function generateNsec() {
const sk = generateSecretKey()
return nsecEncode(sk)
}

View File

@@ -0,0 +1,229 @@
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { ArrowLeft, Check, Copy, Download, RefreshCcw } from 'lucide-react'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import InfoCard from '../InfoCard'
type Step = 'generate' | 'password'
export default function Signup({
back,
onSignupSuccess
}: {
back: () => void
onSignupSuccess: () => void
}) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [step, setStep] = useState<Step>('generate')
const [nsec, setNsec] = useState(generateNsec())
const [checkedSaveKey, setCheckedSaveKey] = useState(false)
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [copied, setCopied] = useState(false)
const handleDownload = () => {
const blob = new Blob([nsec], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'nostr-private-key.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const handleSignup = async () => {
await nsecLogin(nsec, password || undefined, true)
onSignupSuccess()
}
const passwordsMatch = password === confirmPassword
const canSubmit = !password || passwordsMatch
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-2">
{(['generate', 'password'] as Step[]).map((s, index) => (
<div key={s} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${
step === s
? 'bg-primary text-primary-foreground'
: step === 'password' && s === 'generate'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{index + 1}
</div>
{index < 1 && <div className="w-12 h-0.5 bg-muted mx-1" />}
</div>
))}
</div>
)
if (step === 'generate') {
return (
<div className="space-y-6">
{renderStepIndicator()}
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">{t('Generate Your Account')}</h3>
<p className="text-sm text-muted-foreground">
{t('Your private key IS your account. Keep it safe!')}
</p>
</div>
<InfoCard
variant="alert"
title={t('Important')}
content={t(
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.'
)}
/>
<div className="space-y-1">
<Label>{t('Your Private Key')}</Label>
<div className="flex gap-2">
<Input
value={nsec}
readOnly
className="font-mono text-sm"
onClick={(e) => e.currentTarget.select()}
/>
<Button
type="button"
variant="secondary"
size="icon"
onClick={() => setNsec(generateNsec())}
title={t('Generate new key')}
>
<RefreshCcw />
</Button>
</div>
</div>
<div className="w-full flex gap-2 items-center">
<Button onClick={handleDownload} className="w-full">
<Download />
{t('Download Backup File')}
</Button>
<Button
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
variant="secondary"
className="w-full"
>
{copied ? <Check /> : <Copy />}
{copied ? t('Copied to Clipboard') : t('Copy to Clipboard')}
</Button>
</div>
<div className="flex items-center gap-2 ml-2">
<Checkbox
id="acknowledge-checkbox"
checked={checkedSaveKey}
onCheckedChange={(c) => setCheckedSaveKey(!!c)}
/>
<Label htmlFor="acknowledge-checkbox" className="cursor-pointer">
{t('I already saved my private key securely.')}
</Label>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={back} className="w-fit px-6">
<ArrowLeft />
{t('Back')}
</Button>
<Button onClick={() => setStep('password')} className="flex-1" disabled={!checkedSaveKey}>
{t('Continue')}
</Button>
</div>
</div>
)
}
// step === 'password'
return (
<div className="space-y-6">
{renderStepIndicator()}
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">{t('Almost Done!')}</h3>
<p className="text-sm text-muted-foreground">
{t('Set a password to encrypt your key, or skip to finish')}
</p>
</div>
<InfoCard
title={t('Password Protection (Optional)')}
content={t(
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.'
)}
/>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="password-input">{t('Password (Optional)')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('Enter password or leave empty to skip')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{password && (
<div className="space-y-1">
<Label htmlFor="confirm-password-input">{t('Confirm Password')}</Label>
<Input
id="confirm-password-input"
type="password"
placeholder={t('Re-enter password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-500">{t('Passwords do not match')}</p>
)}
</div>
)}
</div>
<div className="w-full flex gap-2">
<Button
variant="secondary"
onClick={() => {
setStep('generate')
setPassword('')
setConfirmPassword('')
}}
className="w-fit px-6"
>
<ArrowLeft />
{t('Back')}
</Button>
<Button onClick={handleSignup} className="flex-1" disabled={!canSubmit}>
{t('Finish Signup')}
</Button>
</div>
</div>
)
}
function generateNsec() {
const sk = generateSecretKey()
return nsecEncode(sk)
}

View File

@@ -2,17 +2,15 @@ import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { isDevEnv } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { NstartModal } from 'nstart-modal'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccountList from '../AccountList'
import GenerateNewAccount from './GenerateNewAccount'
import NostrConnectLogin from './NostrConnectionLogin'
import NpubLogin from './NpubLogin'
import PrivateKeyLogin from './PrivateKeyLogin'
import Signup from './Signup'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null
type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | 'signup' | null
export default function AccountManager({ close }: { close?: () => void }) {
const [page, setPage] = useState<TAccountManagerPage>(null)
@@ -23,10 +21,10 @@ export default function AccountManager({ close }: { close?: () => void }) {
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'bunker' ? (
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'generate' ? (
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'npub' ? (
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'signup' ? (
<Signup back={() => setPage(null)} onSignupSuccess={() => close?.()} />
) : (
<AccountManagerNav setPage={setPage} close={close} />
)}
@@ -41,9 +39,8 @@ function AccountManagerNav({
setPage: (page: TAccountManagerPage) => void
close?: () => void
}) {
const { t, i18n } = useTranslation()
const { themeSetting } = useTheme()
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
const { t } = useTranslation()
const { nip07Login, accounts } = useNostr()
return (
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
@@ -75,38 +72,8 @@ function AccountManagerNav({
<div className="text-center text-muted-foreground text-sm font-semibold">
{t("Don't have an account yet?")}
</div>
<Button
onClick={() => {
const wizard = new NstartModal({
baseUrl: 'https://nstart.me',
an: 'Jumble',
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
al: i18n.language.slice(0, 2),
onComplete: ({ nostrLogin }) => {
if (!nostrLogin) return
if (nostrLogin.startsWith('bunker://')) {
bunkerLogin(nostrLogin)
} else if (nostrLogin.startsWith('ncryptsec')) {
ncryptsecLogin(nostrLogin)
} else if (nostrLogin.startsWith('nsec')) {
nsecLogin(nostrLogin)
}
}
})
close?.()
wizard.open()
}}
className="w-full mt-4"
>
{t('Sign up')}
</Button>
<Button
variant="link"
onClick={() => setPage('generate')}
className="w-full text-muted-foreground py-0 h-fit mt-1"
>
{t('or simply generate a private key')}
<Button onClick={() => setPage('signup')} className="w-full mt-4">
{t('Create New Account')}
</Button>
</div>
{accounts.length > 0 && (

View File

@@ -1,13 +0,0 @@
import { TriangleAlert } from 'lucide-react'
export default function AlertCard({ title, content }: { title: string; content: string }) {
return (
<div className="p-3 rounded-lg text-sm bg-amber-100/20 dark:bg-amber-950/20 border border-amber-500 text-amber-500 [&_svg]:size-4">
<div className="flex items-center gap-2">
<TriangleAlert />
<div className="font-medium">{title}</div>
</div>
<div className="pl-6">{content}</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils'
import { CheckCircle2, Info, TriangleAlert } from 'lucide-react'
const ICON_MAP = {
info: <Info />,
success: <CheckCircle2 />,
alert: <TriangleAlert />
}
const VARIANT_STYLES = {
info: 'bg-blue-100/20 dark:bg-blue-950/20 border border-blue-500 text-blue-500',
success: 'bg-green-100/20 dark:bg-green-950/20 border border-green-500 text-green-500',
alert: 'bg-amber-100/20 dark:bg-amber-950/20 border border-amber-500 text-amber-500'
}
export default function InfoCard({
title,
content,
icon,
variant = 'info'
}: {
title: string
content?: string
icon?: React.ReactNode
variant?: 'info' | 'success' | 'alert'
}) {
return (
<div className={cn('p-3 rounded-lg text-sm [&_svg]:size-4', VARIANT_STYLES[variant])}>
<div className="flex items-center gap-2">
{icon ?? ICON_MAP[variant]}
<div className="font-medium">{title}</div>
</div>
{content && <div className="pl-6">{content}</div>}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { TMailboxRelay } from '@/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AlertCard from '../AlertCard'
import InfoCard from '../InfoCard'
export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] }) {
const { t } = useTranslation()
@@ -19,7 +19,8 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[]
}
return (
<AlertCard
<InfoCard
variant="alert"
title={showReadWarning ? t('Too many read relays') : t('Too many write relays')}
content={
showReadWarning

View File

@@ -8,7 +8,7 @@ import dayjs from 'dayjs'
import { Eraser, X } from 'lucide-react'
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AlertCard from '../AlertCard'
import InfoCard from '../InfoCard'
export default function PollEditor({
pollCreateData,
@@ -125,7 +125,8 @@ export default function PollEditor({
</div>
<div className="grid gap-2">
<AlertCard
<InfoCard
variant="alert"
title={t('This is a poll note.')}
content={t(
'Unlike regular notes, polls are not widely supported and may not display on other clients.'

View File

@@ -34,7 +34,7 @@ export default function TooManyRelaysAlertDialog() {
const dismissed = storage.getDismissedTooManyRelaysAlert()
if (dismissed) return
if (relayList && (relayList.read.length > 4 || relayList.write.length > 4)) {
if (relayList && (relayList.read.length > 5 || relayList.write.length > 5)) {
setOpen(true)
} else {
setOpen(false)

View File

@@ -25,7 +25,7 @@ const buttonVariants = cva(
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-lg px-8',
icon: 'h-9 w-9',
icon: 'h-9 w-9 shrink-0',
'titlebar-icon': 'h-10 w-10 shrink-0 rounded-xl [&_svg]:size-5'
}
},

View File

@@ -1,4 +1,5 @@
import { kinds } from 'nostr-tools'
import { TMailboxRelay } from './types'
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social'
@@ -71,6 +72,13 @@ export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.n
export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/']
export const NEW_USER_RELAY_LIST: TMailboxRelay[] = [
{ url: 'wss://nos.lol/', scope: 'both' },
{ url: 'wss://offchain.pub/', scope: 'both' },
{ url: 'wss://relay.damus.io/', scope: 'both' },
{ url: 'wss://nostr.mom/', scope: 'both' }
]
export const GROUP_METADATA_EVENT_KIND = 39000
export const ExtendedKind = {

View File

@@ -1,5 +1,5 @@
import LoginDialog from '@/components/LoginDialog'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, NEW_USER_RELAY_LIST } from '@/constants'
import {
createDeletionRequestDraftEvent,
createFollowListDraftEvent,
@@ -614,14 +614,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const setupNewUser = async (signer: ISigner) => {
const relays = NEW_USER_RELAY_LIST.map((item) => item.url)
await Promise.allSettled([
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createFollowListDraftEvent([]))),
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createMuteListDraftEvent([]))),
client.publishEvent(relays, await signer.signEvent(createFollowListDraftEvent([]))),
client.publishEvent(relays, await signer.signEvent(createMuteListDraftEvent([]))),
client.publishEvent(
BIG_RELAY_URLS,
await signer.signEvent(
createRelayListDraftEvent(BIG_RELAY_URLS.map((url) => ({ url, scope: 'both' })))
)
relays.concat(BIG_RELAY_URLS),
await signer.signEvent(createRelayListDraftEvent(NEW_USER_RELAY_LIST))
)
])
}