Browse Source

implement onboarding

imwald
Silberengel 2 weeks ago
parent
commit
48bfdec578
  1. 6
      package-lock.json
  2. 1
      package.json
  3. 85
      src/components/AccountManager/GenerateNewAccount.tsx
  4. 83
      src/components/AccountManager/index.tsx
  5. 92
      src/components/PrivateKeyRecoverySetting/index.tsx
  6. 41
      src/components/Settings/SettingsMenuBody.tsx
  7. 19
      src/i18n/locales/en.ts
  8. 69
      src/lib/new-user-template.test.ts
  9. 109
      src/lib/new-user-template.ts
  10. 2
      src/pages/secondary/CacheSettingsPage/index.tsx
  11. 81
      src/providers/NostrProvider/index.tsx
  12. 4
      vite.config.ts

6
package-lock.json generated

@ -77,7 +77,6 @@
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"marked": "^17.0.5", "marked": "^17.0.5",
"nostr-tools": "^2.17.0", "nostr-tools": "^2.17.0",
"nstart-modal": "^2.0.0",
"path-to-regexp": "^8.3.0", "path-to-regexp": "^8.3.0",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",
@ -12895,11 +12894,6 @@
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nstart-modal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/nstart-modal/-/nstart-modal-2.1.0.tgz",
"integrity": "sha512-PolShYoWK07yJJbINtUn/IoOI5B0lmXRG9zOY9dirKKVjmMWFKjYLlafunNOl94EGcEndzAPWJAFDCt8flYRqg=="
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

1
package.json

@ -107,7 +107,6 @@
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"marked": "^17.0.5", "marked": "^17.0.5",
"nostr-tools": "^2.17.0", "nostr-tools": "^2.17.0",
"nstart-modal": "^2.0.0",
"path-to-regexp": "^8.3.0", "path-to-regexp": "^8.3.0",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",

85
src/components/AccountManager/GenerateNewAccount.tsx

@ -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)
}

83
src/components/AccountManager/index.tsx

@ -1,17 +1,19 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider' import { generateSecretKey } from 'nostr-tools'
import { NstartModal } from 'nstart-modal' import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import AccountList from '../AccountList' import AccountList from '../AccountList'
import GenerateNewAccount from './GenerateNewAccount'
import NostrConnectLogin from './NostrConnectionLogin' import NostrConnectLogin from './NostrConnectionLogin'
import NpubLogin from './NpubLogin' import NpubLogin from './NpubLogin'
import PrivateKeyLogin from './PrivateKeyLogin' import PrivateKeyLogin from './PrivateKeyLogin'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | null
export default function AccountManager({ close }: { close?: () => void }) { export default function AccountManager({ close }: { close?: () => void }) {
const [page, setPage] = useState<TAccountManagerPage>(null) const [page, setPage] = useState<TAccountManagerPage>(null)
@ -22,8 +24,6 @@ export default function AccountManager({ close }: { close?: () => void }) {
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} /> <PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'bunker' ? ( ) : page === 'bunker' ? (
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} /> <NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'generate' ? (
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'npub' ? ( ) : page === 'npub' ? (
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} /> <NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : ( ) : (
@ -40,9 +40,24 @@ function AccountManagerNav({
setPage: (page: TAccountManagerPage) => void setPage: (page: TAccountManagerPage) => void
close?: () => void close?: () => void
}) { }) {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const { themeSetting } = useTheme() const { nip07Login, nsecLogin, accounts } = useNostr()
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr() const [password, setPassword] = useState('')
const [signingUp, setSigningUp] = useState(false)
const handleSignUp = async () => {
setSigningUp(true)
try {
const nsec = nsecEncode(generateSecretKey())
await nsecLogin(nsec, password.trim() || undefined, true)
setPassword('')
close?.()
} catch (error) {
toast.error(t('Login failed') + ': ' + ((error as Error).message ?? String(error)))
} finally {
setSigningUp(false)
}
}
return ( return (
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8"> <div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
@ -72,38 +87,24 @@ function AccountManagerNav({
<div className="text-center text-muted-foreground text-sm font-semibold"> <div className="text-center text-muted-foreground text-sm font-semibold">
{t("Don't have an account yet?")} {t("Don't have an account yet?")}
</div> </div>
<Button <p className="text-center text-muted-foreground text-xs mt-2 px-2">
onClick={() => { {t(
const wizard = new NstartModal({ 'Sign up creates a private key stored in this browser. Back it up anytime under Settings → Cache & offline storage.'
baseUrl: 'https://nstart.me', )}
an: 'Imwald', </p>
am: themeSetting, <div className="grid gap-2 mt-3">
al: i18n.language.slice(0, 2), <Label htmlFor="signup-password-input">{t('password')}</Label>
onComplete: ({ nostrLogin }) => { <Input
if (!nostrLogin) return id="signup-password-input"
type="password"
if (nostrLogin.startsWith('bunker://')) { placeholder={t('optional: encrypt nsec')}
bunkerLogin(nostrLogin) value={password}
} else if (nostrLogin.startsWith('ncryptsec')) { onChange={(e) => setPassword(e.target.value)}
ncryptsecLogin(nostrLogin) disabled={signingUp}
} else if (nostrLogin.startsWith('nsec')) { />
nsecLogin(nostrLogin) </div>
} <Button onClick={handleSignUp} disabled={signingUp} className="w-full mt-4">
} {signingUp ? t('Signing up…') : t('Sign up')}
})
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> </Button>
</div> </div>
{accounts.length > 0 && ( {accounts.length > 0 && (

92
src/components/PrivateKeyRecoverySetting/index.tsx

@ -0,0 +1,92 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { pubkeyToNpub } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, Eye, EyeOff, KeyRound } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function PrivateKeyRecoverySetting() {
const { t } = useTranslation()
const { pubkey, nsec, ncryptsec } = useNostr()
const [showKey, setShowKey] = useState(false)
const [copiedNpub, setCopiedNpub] = useState(false)
const [copiedKey, setCopiedKey] = useState(false)
const npub = useMemo(() => (pubkey ? pubkeyToNpub(pubkey) : null), [pubkey])
const recoverableKey = nsec ?? ncryptsec
const keyLabel = nsec ? 'nsec' : 'ncryptsec'
if (!pubkey || !recoverableKey) {
return null
}
const copyToClipboard = async (text: string, which: 'npub' | 'key') => {
await navigator.clipboard.writeText(text)
if (which === 'npub') {
setCopiedNpub(true)
setTimeout(() => setCopiedNpub(false), 2000)
} else {
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}
}
return (
<section className="space-y-4">
<div className="flex items-center gap-2">
<KeyRound className="size-4 shrink-0 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t('Private key recovery')}</h2>
</div>
<p className="text-sm text-muted-foreground">
{t(
'Your private key is stored in this browser. Clearing cache does not remove your account, but losing this browser profile does. Back up your key somewhere safe.'
)}
</p>
{ncryptsec && !nsec && (
<p className="text-sm text-muted-foreground">
{t(
'This account uses an encrypted key (ncryptsec). You need your encryption password to sign in; the blob below is for backup only.'
)}
</p>
)}
<div className="grid gap-2">
<Label>{t('npub')}</Label>
<div className="flex gap-2">
<Input readOnly value={npub ?? ''} className="font-mono text-xs" />
<Button
type="button"
variant="secondary"
size="icon"
aria-label={t('Copy npub')}
onClick={() => npub && copyToClipboard(npub, 'npub')}
>
{copiedNpub ? <Check /> : <Copy />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label>{t('Copy private key')} ({keyLabel})</Label>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={() => copyToClipboard(recoverableKey, 'key')}>
{copiedKey ? <Check className="mr-2 size-4" /> : <Copy className="mr-2 size-4" />}
{t('Copy private key')}
</Button>
<Button type="button" variant="outline" onClick={() => setShowKey((v) => !v)}>
{showKey ? <EyeOff className="mr-2 size-4" /> : <Eye className="mr-2 size-4" />}
{showKey ? t('Hide key') : t('Show key')}
</Button>
</div>
{showKey && (
<div className="rounded-md border bg-muted/40 p-3">
<p className="text-xs text-orange-500 mb-2">
{t('Do not share this with anyone. Anyone with this key can control your account.')}
</p>
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{recoverableKey}</pre>
</div>
)}
</div>
</section>
)
}

41
src/components/Settings/SettingsMenuBody.tsx

@ -12,12 +12,9 @@ import { cn } from '@/lib/utils'
import { useSmartSettingsNavigation } from '@/PageManager' import { useSmartSettingsNavigation } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { import {
Check,
ChevronRight, ChevronRight,
Copy,
Database, Database,
Info, Info,
KeyRound,
PencilLine, PencilLine,
Rss, Rss,
Server, Server,
@ -25,7 +22,7 @@ import {
Users, Users,
Wallet Wallet
} from 'lucide-react' } from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react' import { forwardRef, HTMLProps } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
/** /**
@ -34,10 +31,8 @@ import { useTranslation } from 'react-i18next'
*/ */
export default function SettingsMenuBody({ className }: { className?: string }) { export default function SettingsMenuBody({ className }: { className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, nsec, ncryptsec } = useNostr() const { pubkey } = useNostr()
const { navigateToSettings } = useSmartSettingsNavigation() const { navigateToSettings } = useSmartSettingsNavigation()
const [copiedNsec, setCopiedNsec] = useState(false)
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
return ( return (
<div className={cn('min-w-0', className)}> <div className={cn('min-w-0', className)}>
@ -98,38 +93,6 @@ export default function SettingsMenuBody({ className }: { className?: string })
<ChevronRight /> <ChevronRight />
</SettingItem> </SettingItem>
)} )}
{!!nsec && (
<SettingItem
className="clickable"
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopiedNsec(true)
setTimeout(() => setCopiedNsec(false), 2000)
}}
>
<div className="flex items-center gap-4">
<KeyRound />
<div>{t('Copy private key')} (nsec)</div>
</div>
{copiedNsec ? <Check /> : <Copy />}
</SettingItem>
)}
{!!ncryptsec && (
<SettingItem
className="clickable"
onClick={() => {
navigator.clipboard.writeText(ncryptsec)
setCopiedNcryptsec(true)
setTimeout(() => setCopiedNcryptsec(false), 2000)
}}
>
<div className="flex items-center gap-4">
<KeyRound />
<div>{t('Copy private key')} (ncryptsec)</div>
</div>
{copiedNcryptsec ? <Check /> : <Copy />}
</SettingItem>
)}
<AboutInfoDialog> <AboutInfoDialog>
<SettingItem className="clickable"> <SettingItem className="clickable">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

19
src/i18n/locales/en.ts

@ -539,9 +539,22 @@ export default {
'read & write relays notice': 'read & write relays notice':
'The number of read and write servers should ideally be kept between 2 and 4.', 'The number of read and write servers should ideally be kept between 2 and 4.',
"Don't have an account yet?": "Don't have an account yet?", "Don't have an account yet?": "Don't have an account yet?",
'or simply generate a private key': 'or simply generate a private key', 'Sign up creates a private key stored in this browser. Back it up anytime under Settings → Cache & offline storage.':
'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.': 'Sign up creates a private key stored in this browser. Back it up anytime under Settings → Cache & offline storage.',
'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.', 'Signing up…': 'Signing up…',
'Account created — customize profile and relays in Settings.':
'Account created — customize profile and relays in Settings.',
'Private key recovery': 'Private key recovery',
'Your private key is stored in this browser. Clearing cache does not remove your account, but losing this browser profile does. Back up your key somewhere safe.':
'Your private key is stored in this browser. Clearing cache does not remove your account, but losing this browser profile does. Back up your key somewhere safe.',
'This account uses an encrypted key (ncryptsec). You need your encryption password to sign in; the blob below is for backup only.':
'This account uses an encrypted key (ncryptsec). You need your encryption password to sign in; the blob below is for backup only.',
'Show key': 'Show key',
'Hide key': 'Hide key',
'Do not share this with anyone. Anyone with this key can control your account.':
'Do not share this with anyone. Anyone with this key can control your account.',
'Copy npub': 'Copy npub',
npub: 'npub',
Edit: 'Edit', Edit: 'Edit',
Save: 'Save', Save: 'Save',
'Display Name': 'Display Name', 'Display Name': 'Display Name',

69
src/lib/new-user-template.test.ts

@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools'
import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import {
NEW_USER_INTEREST_TOPICS,
buildNewUserTemplateDrafts,
newUserProfileDisplayName,
newUserProfileName,
newUserProfileSuffix
} from '@/lib/new-user-template'
const TEST_PUBKEY = 'a'.repeat(63) + 'b'
describe('newUserProfileSuffix', () => {
it('returns a number between 1000 and 9999', () => {
const suffix = newUserProfileSuffix(TEST_PUBKEY)
expect(suffix).toBeGreaterThanOrEqual(1000)
expect(suffix).toBeLessThanOrEqual(9999)
})
it('formats profile names with the suffix', () => {
const suffix = newUserProfileSuffix(TEST_PUBKEY)
expect(newUserProfileName(TEST_PUBKEY)).toBe(`ImwaldUser${suffix}`)
expect(newUserProfileDisplayName(TEST_PUBKEY)).toBe(`Imwald User ${suffix}`)
})
})
describe('buildNewUserTemplateDrafts', () => {
const drafts = buildNewUserTemplateDrafts(TEST_PUBKEY)
it('builds profile kind 0 with unique names', () => {
expect(drafts.profile.kind).toBe(kinds.Metadata)
const profile = JSON.parse(drafts.profile.content)
expect(profile.name).toBe(newUserProfileName(TEST_PUBKEY))
expect(profile.display_name).toBe(newUserProfileDisplayName(TEST_PUBKEY))
expect(profile.about).toContain('Imwald')
})
it('builds favorite relays kind 10012', () => {
expect(drafts.favoriteRelays.kind).toBe(ExtendedKind.FAVORITE_RELAYS)
expect(drafts.favoriteRelays.tags.filter((t) => t[0] === 'relay')).toHaveLength(2)
})
it('splits mailbox read and write relays', () => {
expect(drafts.relayList.kind).toBe(kinds.RelayList)
const readTags = drafts.relayList.tags.filter((t) => t[0] === 'r' && t[2] === 'read')
const writeTags = drafts.relayList.tags.filter((t) => t[0] === 'r' && t[2] === 'write')
expect(readTags).toHaveLength(FAST_READ_RELAY_URLS.length)
expect(writeTags).toHaveLength(FAST_WRITE_RELAY_URLS.length)
})
it('builds HTTP relay list kind 10243 with mercury', () => {
expect(drafts.httpRelayList.kind).toBe(ExtendedKind.HTTP_RELAY_LIST)
expect(drafts.httpRelayList.tags.some((t) => t[1]?.includes('mercury-relay.imwald.eu'))).toBe(true)
})
it('builds interest list with expected topics', () => {
expect(drafts.interestList.kind).toBe(10015)
const topics = drafts.interestList.tags.filter((t) => t[0] === 't').map((t) => t[1])
expect(topics).toEqual([...NEW_USER_INTEREST_TOPICS])
})
it('builds empty follow and mute lists', () => {
expect(drafts.followList.kind).toBe(kinds.Contacts)
expect(drafts.followList.tags).toHaveLength(0)
expect(drafts.muteList.kind).toBe(10000)
expect(drafts.muteList.tags).toHaveLength(0)
})
})

109
src/lib/new-user-template.ts

@ -0,0 +1,109 @@
import {
DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS
} from '@/constants'
import {
createFavoriteRelaysDraftEvent,
createFollowListDraftEvent,
createHttpRelayListDraftEvent,
createInterestListDraftEvent,
createMuteListDraftEvent,
createProfileDraftEvent,
createRelayListDraftEvent
} from '@/lib/draft-event'
import { TDraftEvent, TMailboxRelay } from '@/types'
export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/'
export const NEW_USER_INTEREST_TOPICS = [
'art',
'music',
'news',
'foodstr',
'coffeechain',
'travel',
'grownostr',
'plebchain'
] as const
export const NEW_USER_PROFILE_ABOUT = 'New on Nostr via Imwald. Edit your profile in Settings.'
/** Stable 4-digit suffix (1000–9999) from pubkey hex. */
export function newUserProfileSuffix(pubkey: string): number {
const hex = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(hex)) {
return 1000
}
return (parseInt(hex.slice(-4), 16) % 9000) + 1000
}
export function newUserProfileName(pubkey: string): string {
return `ImwaldUser${newUserProfileSuffix(pubkey)}`
}
export function newUserProfileDisplayName(pubkey: string): string {
return `Imwald User ${newUserProfileSuffix(pubkey)}`
}
export function buildNewUserMailboxRelays(): TMailboxRelay[] {
return [
...FAST_READ_RELAY_URLS.map((url) => ({ url, scope: 'read' as const })),
...FAST_WRITE_RELAY_URLS.map((url) => ({ url, scope: 'write' as const }))
]
}
export function buildNewUserProfileDraft(pubkey: string): TDraftEvent {
const content = JSON.stringify({
name: newUserProfileName(pubkey),
display_name: newUserProfileDisplayName(pubkey),
about: NEW_USER_PROFILE_ABOUT
})
return createProfileDraftEvent(content)
}
export function buildNewUserFavoriteRelaysDraft(): TDraftEvent {
return createFavoriteRelaysDraftEvent([...DEFAULT_FAVORITE_RELAYS], [])
}
export function buildNewUserRelayListDraft(): TDraftEvent {
return createRelayListDraftEvent(buildNewUserMailboxRelays())
}
export function buildNewUserHttpRelayListDraft(): TDraftEvent {
return createHttpRelayListDraftEvent([{ url: NEW_USER_HTTP_RELAY_URL, scope: 'both' }])
}
export function buildNewUserInterestListDraft(): TDraftEvent {
return createInterestListDraftEvent([...NEW_USER_INTEREST_TOPICS])
}
export function buildNewUserFollowListDraft(): TDraftEvent {
return createFollowListDraftEvent([])
}
export function buildNewUserMuteListDraft(): TDraftEvent {
return createMuteListDraftEvent([])
}
export type TNewUserTemplateDrafts = {
profile: TDraftEvent
favoriteRelays: TDraftEvent
relayList: TDraftEvent
httpRelayList: TDraftEvent
interestList: TDraftEvent
followList: TDraftEvent
muteList: TDraftEvent
}
export function buildNewUserTemplateDrafts(pubkey: string): TNewUserTemplateDrafts {
return {
profile: buildNewUserProfileDraft(pubkey),
favoriteRelays: buildNewUserFavoriteRelaysDraft(),
relayList: buildNewUserRelayListDraft(),
httpRelayList: buildNewUserHttpRelayListDraft(),
interestList: buildNewUserInterestListDraft(),
followList: buildNewUserFollowListDraft(),
muteList: buildNewUserMuteListDraft()
}
}

2
src/pages/secondary/CacheSettingsPage/index.tsx

@ -1,6 +1,7 @@
import CacheEventImportSettings from '@/components/CacheEventImportSettings' import CacheEventImportSettings from '@/components/CacheEventImportSettings'
import InBrowserCacheSetting from '@/components/InBrowserCacheSetting' import InBrowserCacheSetting from '@/components/InBrowserCacheSetting'
import EventArchiveCacheSettings from '@/components/EventArchiveCacheSettings' import EventArchiveCacheSettings from '@/components/EventArchiveCacheSettings'
import PrivateKeyRecoverySetting from '@/components/PrivateKeyRecoverySetting'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
@ -31,6 +32,7 @@ const CacheSettingsPage = forwardRef(
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />} controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
> >
<div key={contentKey} className="px-4 py-3 space-y-6"> <div key={contentKey} className="px-4 py-3 space-y-6">
<PrivateKeyRecoverySetting />
<InBrowserCacheSetting /> <InBrowserCacheSetting />
<CacheEventImportSettings /> <CacheEventImportSettings />
<EventArchiveCacheSettings /> <EventArchiveCacheSettings />

81
src/providers/NostrProvider/index.tsx

@ -6,6 +6,7 @@ import {
ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS, ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS,
DEFAULT_FAVORITE_RELAYS, DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS,
AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS, AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS,
ExtendedKind, ExtendedKind,
PROFILE_RELAY_URLS, PROFILE_RELAY_URLS,
@ -16,11 +17,9 @@ import {
} from '@/constants' } from '@/constants'
import { import {
applyImwaldAttributionTags, applyImwaldAttributionTags,
createDeletionRequestDraftEvent, createDeletionRequestDraftEvent
createFollowListDraftEvent,
createMuteListDraftEvent,
createRelayListDraftEvent
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { buildNewUserTemplateDrafts } from '@/lib/new-user-template'
import { getLatestEvent, minePow } from '@/lib/event' import { getLatestEvent, minePow } from '@/lib/event'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { import {
@ -1506,27 +1505,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
options?: { addClientTag?: boolean } options?: { addClientTag?: boolean }
): TDraftEvent => applyImwaldAttributionTags(draftEvent, options) ): TDraftEvent => applyImwaldAttributionTags(draftEvent, options)
const setupNewUser = async (signer: ISigner) => {
await Promise.allSettled([
client.publishEvent(
FAST_READ_RELAY_URLS,
await signer.signEvent(normalizeDraftEventTags(createFollowListDraftEvent([])))
),
client.publishEvent(
FAST_READ_RELAY_URLS,
await signer.signEvent(normalizeDraftEventTags(createMuteListDraftEvent([])))
),
client.publishEvent(
FAST_READ_RELAY_URLS,
await signer.signEvent(
normalizeDraftEventTags(
createRelayListDraftEvent(FAST_READ_RELAY_URLS.map((url) => ({ url, scope: 'both' })))
)
)
)
])
}
const signEvent = async ( const signEvent = async (
draftEvent: TDraftEvent, draftEvent: TDraftEvent,
normalizeOpts?: { addClientTag?: boolean } normalizeOpts?: { addClientTag?: boolean }
@ -1915,6 +1893,59 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFavoriteRelaysEvent(stored) setFavoriteRelaysEvent(stored)
} }
const setupNewUser = async (signer: ISigner) => {
const bootstrapRelays = [...new Set([...FAST_WRITE_RELAY_URLS, ...FAST_READ_RELAY_URLS])]
try {
const pubkey = await signer.getPublicKey()
const drafts = buildNewUserTemplateDrafts(pubkey)
const signDraft = async (draft: TDraftEvent) => {
const event = await signer.signEvent(normalizeDraftEventTags(draft))
if (!validateEvent(event)) {
throw new Error('Event validation failed')
}
return event as VerifiedEvent
}
const profileEvent = await signDraft(drafts.profile)
const favoriteRelaysEvent = await signDraft(drafts.favoriteRelays)
const relayListEvent = await signDraft(drafts.relayList)
const httpRelayListEvent = await signDraft(drafts.httpRelayList)
const interestListEvent = await signDraft(drafts.interestList)
const followListEvent = await signDraft(drafts.followList)
const muteListEvent = await signDraft(drafts.muteList)
await Promise.all([
updateProfileEvent(profileEvent),
updateFavoriteRelaysEvent(favoriteRelaysEvent),
updateRelayListEvent(relayListEvent),
updateHttpRelayListEvent(httpRelayListEvent),
updateInterestListEvent(interestListEvent),
updateFollowListEvent(followListEvent),
updateMuteListEvent(muteListEvent, [])
])
await Promise.allSettled(
[
profileEvent,
favoriteRelaysEvent,
relayListEvent,
httpRelayListEvent,
interestListEvent,
followListEvent,
muteListEvent
].map((event) => client.publishEvent(bootstrapRelays, event))
)
toast.success(
t('Account created — customize profile and relays in Settings.')
)
} catch (error) {
logger.error('[setupNewUser] failed', { error })
}
}
const updateBlockedRelaysEvent = async (blockedRelaysEvent: Event) => { const updateBlockedRelaysEvent = async (blockedRelaysEvent: Event) => {
const newBlockedRelaysEvent = await indexedDb.putReplaceableEvent(blockedRelaysEvent) const newBlockedRelaysEvent = await indexedDb.putReplaceableEvent(blockedRelaysEvent)
if (newBlockedRelaysEvent.id !== blockedRelaysEvent.id) return if (newBlockedRelaysEvent.id !== blockedRelaysEvent.id) return

4
vite.config.ts

@ -357,10 +357,6 @@ export default defineConfig(({ mode }) => {
if (norm.includes('@getalby') || norm.includes('bitcoin-connect')) { if (norm.includes('@getalby') || norm.includes('bitcoin-connect')) {
return 'vendor-lightning-alby' return 'vendor-lightning-alby'
} }
if (norm.includes('nstart-modal')) {
return 'vendor-lightning-nstart'
}
if (norm.includes('embla-carousel')) { if (norm.includes('embla-carousel')) {
return 'vendor-embla' return 'vendor-embla'
} }

Loading…
Cancel
Save