diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 4f019a8d..05cdc098 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -539,9 +539,22 @@ export default {
'read & write relays notice':
'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?",
- 'or simply generate a private key': 'or simply generate a private key',
- '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.':
- '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.':
+ 'Sign up creates a private key stored in this browser. Back it up anytime under Settings → Cache & offline storage.',
+ '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',
Save: 'Save',
'Display Name': 'Display Name',
diff --git a/src/lib/new-user-template.test.ts b/src/lib/new-user-template.test.ts
new file mode 100644
index 00000000..6ae38832
--- /dev/null
+++ b/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)
+ })
+})
diff --git a/src/lib/new-user-template.ts b/src/lib/new-user-template.ts
new file mode 100644
index 00000000..b13b6af4
--- /dev/null
+++ b/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()
+ }
+}
diff --git a/src/pages/secondary/CacheSettingsPage/index.tsx b/src/pages/secondary/CacheSettingsPage/index.tsx
index d219791a..d71d628c 100644
--- a/src/pages/secondary/CacheSettingsPage/index.tsx
+++ b/src/pages/secondary/CacheSettingsPage/index.tsx
@@ -1,6 +1,7 @@
import CacheEventImportSettings from '@/components/CacheEventImportSettings'
import InBrowserCacheSetting from '@/components/InBrowserCacheSetting'
import EventArchiveCacheSettings from '@/components/EventArchiveCacheSettings'
+import PrivateKeyRecoverySetting from '@/components/PrivateKeyRecoverySetting'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
@@ -31,6 +32,7 @@ const CacheSettingsPage = forwardRef(
controls={hideTitlebar ? undefined :
}
>
+
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx
index ea1e71c5..cf376bab 100644
--- a/src/providers/NostrProvider/index.tsx
+++ b/src/providers/NostrProvider/index.tsx
@@ -6,6 +6,7 @@ import {
ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS,
DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS,
+ FAST_WRITE_RELAY_URLS,
AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS,
ExtendedKind,
PROFILE_RELAY_URLS,
@@ -16,11 +17,9 @@ import {
} from '@/constants'
import {
applyImwaldAttributionTags,
- createDeletionRequestDraftEvent,
- createFollowListDraftEvent,
- createMuteListDraftEvent,
- createRelayListDraftEvent
+ createDeletionRequestDraftEvent
} from '@/lib/draft-event'
+import { buildNewUserTemplateDrafts } from '@/lib/new-user-template'
import { getLatestEvent, minePow } from '@/lib/event'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import {
@@ -1506,27 +1505,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
options?: { addClientTag?: boolean }
): 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 (
draftEvent: TDraftEvent,
normalizeOpts?: { addClientTag?: boolean }
@@ -1915,6 +1893,59 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
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 newBlockedRelaysEvent = await indexedDb.putReplaceableEvent(blockedRelaysEvent)
if (newBlockedRelaysEvent.id !== blockedRelaysEvent.id) return
diff --git a/vite.config.ts b/vite.config.ts
index 20d3078c..5ff69fdf 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -357,10 +357,6 @@ export default defineConfig(({ mode }) => {
if (norm.includes('@getalby') || norm.includes('bitcoin-connect')) {
return 'vendor-lightning-alby'
}
- if (norm.includes('nstart-modal')) {
- return 'vendor-lightning-nstart'
- }
-
if (norm.includes('embla-carousel')) {
return 'vendor-embla'
}