diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 1ec97689..caff0bb5 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -104,7 +104,7 @@ const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrima const SidebarLazy = lazy(() => import('@/components/Sidebar')) const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar')) const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog')) -const CreateWalletGuideToastLazy = lazy(() => import('@/components/CreateWalletGuideToast')) +const PostSignupBackupRedirectLazy = lazy(() => import('@/components/PostSignupBackupRedirect')) /** Mobile primary-note overlay: lazy so these pages are not in the main bundle (routes use the same modules → shared async chunks). */ const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage')) @@ -2414,7 +2414,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { - + @@ -2549,7 +2549,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { - + diff --git a/src/components/CreateWalletGuideToast/index.tsx b/src/components/CreateWalletGuideToast/index.tsx deleted file mode 100644 index 14b30a85..00000000 --- a/src/components/CreateWalletGuideToast/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import storage from '@/services/local-storage.service' -import { toWallet } from '@/lib/link' -import { useSecondaryPage } from '@/contexts/secondary-page-context' -import { useNostr } from '@/providers/NostrProvider' -import { useEffect } from 'react' -import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' - -export default function CreateWalletGuideToast() { - const { t } = useTranslation() - const { push } = useSecondaryPage() - const { profile } = useNostr() - - useEffect(() => { - if ( - profile && - !profile.lightningAddress && - !storage.hasShownCreateWalletGuideToast(profile.pubkey) - ) { - toast(t('Set up your wallet to send and receive sats!'), { - action: { - label: t('Set up'), - onClick: () => push(toWallet()) - } - }) - storage.markCreateWalletGuideToastAsShown(profile.pubkey) - } - }, [profile]) - - return null -} diff --git a/src/components/LogoutDialog/index.tsx b/src/components/LogoutDialog/index.tsx index 236f9773..774ce4e2 100644 --- a/src/components/LogoutDialog/index.tsx +++ b/src/components/LogoutDialog/index.tsx @@ -17,6 +17,7 @@ import { DrawerHeader, DrawerTitle } from '@/components/ui/drawer' +import { usePrimaryPage } from '@/contexts/primary-page-context' import { useNostr } from '@/providers/NostrProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useTranslation } from 'react-i18next' @@ -31,10 +32,12 @@ export default function LogoutDialog({ const { t } = useTranslation() const { isSmallScreen = false } = useScreenSizeOptional() ?? {} const { account, switchAccount } = useNostr() + const { navigate } = usePrimaryPage() const handleLogout = () => { setOpen(false) void switchAccount(null) + navigate('feed') } if (isSmallScreen) { diff --git a/src/components/PostSignupBackupRedirect/index.tsx b/src/components/PostSignupBackupRedirect/index.tsx new file mode 100644 index 00000000..ee292e59 --- /dev/null +++ b/src/components/PostSignupBackupRedirect/index.tsx @@ -0,0 +1,49 @@ +import { toCacheSettings } from '@/lib/link' +import { + consumePostSignupBackupPrompt, + showNewUserBackupBanner +} from '@/lib/post-signup-backup-prompt' +import { useSecondaryPage } from '@/contexts/secondary-page-context' +import { useNostr } from '@/providers/NostrProvider' +import { useEffect, useRef } from 'react' + +/** After one-click sign up, open Cache settings so the user can back up their private key. */ +export default function PostSignupBackupRedirect() { + const { push } = useSecondaryPage() + const { pubkey } = useNostr() + const pollRef = useRef | null>(null) + + useEffect(() => { + if (!pubkey) return + + const tryRedirect = () => { + if (!consumePostSignupBackupPrompt(pubkey)) return false + showNewUserBackupBanner() + push(toCacheSettings()) + return true + } + + if (tryRedirect()) return + + // Prompt is scheduled at login; brief poll covers pubkey/login race. + let attempts = 0 + pollRef.current = setInterval(() => { + attempts += 1 + if (tryRedirect() || attempts >= 15) { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + } + }, 200) + + return () => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + } + }, [pubkey, push]) + + return null +} diff --git a/src/components/PrivateKeyRecoverySetting/index.tsx b/src/components/PrivateKeyRecoverySetting/index.tsx index b7189606..acbd1dd4 100644 --- a/src/components/PrivateKeyRecoverySetting/index.tsx +++ b/src/components/PrivateKeyRecoverySetting/index.tsx @@ -1,24 +1,65 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { + dismissNewUserBackupBanner, + isNewUserBackupBannerVisible +} from '@/lib/post-signup-backup-prompt' +import { requestNewUserTemplateBroadcast } from '@/lib/new-user-template-broadcast' +import NcryptsecPasswordPrompt from '@/components/NcryptsecPasswordPrompt' 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 storage from '@/services/local-storage.service' +import { Check, Copy, Eye, EyeOff, KeyRound, Trash2, X } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import * as nip19 from 'nostr-tools/nip19' +import * as nip49 from 'nostr-tools/nip49' +import { toast } from 'sonner' export default function PrivateKeyRecoverySetting() { const { t } = useTranslation() - const { pubkey, nsec, ncryptsec } = useNostr() + const { pubkey, account, nsec, ncryptsec, discardLocalPrivateKey } = useNostr() const [showKey, setShowKey] = useState(false) + const [revealedNsec, setRevealedNsec] = useState(null) + const [passwordPromptOpen, setPasswordPromptOpen] = useState(false) const [copiedNpub, setCopiedNpub] = useState(false) const [copiedKey, setCopiedKey] = useState(false) + const [showBackupBanner, setShowBackupBanner] = useState(false) + const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false) + + useEffect(() => { + setShowBackupBanner(isNewUserBackupBannerVisible()) + }, []) const npub = useMemo(() => (pubkey ? pubkeyToNpub(pubkey) : null), [pubkey]) - const recoverableKey = nsec ?? ncryptsec - const keyLabel = nsec ? 'nsec' : 'ncryptsec' - if (!pubkey || !recoverableKey) { + const storedNsec = pubkey ? storage.getAccountNsec(pubkey) : undefined + const storedNcryptsec = pubkey ? storage.getAccountNcryptsec(pubkey) : undefined + const plainNsec = nsec ?? storedNsec + const encryptedBlob = ncryptsec ?? storedNcryptsec + const usesEncryption = !!encryptedBlob && !plainNsec + const recoverableKey = plainNsec ?? encryptedBlob + const displayedKey = revealedNsec ?? (showKey && !usesEncryption ? recoverableKey : null) + const copyKeyValue = revealedNsec ?? recoverableKey + const keyLabel = revealedNsec || plainNsec ? 'nsec' : 'ncryptsec' + const hasLocalKey = + account?.signerType === 'nsec' || + account?.signerType === 'ncryptsec' || + !!storedNsec || + !!storedNcryptsec + + if (!pubkey || !hasLocalKey || !recoverableKey) { return null } @@ -33,8 +74,71 @@ export default function PrivateKeyRecoverySetting() { } } + const dismissBanner = () => { + dismissNewUserBackupBanner() + setShowBackupBanner(false) + requestNewUserTemplateBroadcast(pubkey) + } + + const handleToggleShowKey = () => { + if (showKey) { + setShowKey(false) + setRevealedNsec(null) + return + } + if (usesEncryption) { + setPasswordPromptOpen(true) + return + } + setShowKey(true) + } + + const handleDecryptPassword = (password: string | null) => { + setPasswordPromptOpen(false) + if (!password || !encryptedBlob) return + try { + const privkey = nip49.decrypt(encryptedBlob, password) + setRevealedNsec(nip19.nsecEncode(privkey)) + setShowKey(true) + } catch { + toast.error(t('Could not decrypt — check your password and try again.')) + } + } + + const handleRemoveLocalKey = () => { + try { + discardLocalPrivateKey() + dismissBanner() + setRemoveConfirmOpen(false) + toast.success( + t( + 'Local private key removed. This account is read-only here until you log in with an extension, bunker, or private key again.' + ) + ) + } catch (error) { + toast.error((error as Error).message) + } + } + return (
+ {showBackupBanner && ( +
+
+

+ {t('Back up your private key now')} +

+ +
+

+ {t( + 'Your account was just created. Copy your nsec (or ncryptsec) below and store it somewhere safe — password manager, encrypted file, or paper offline. Anyone with this key controls your account.' + )} +

+
+ )}

{t('Private key recovery')}

@@ -44,10 +148,10 @@ export default function PrivateKeyRecoverySetting() { '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.' )}

- {ncryptsec && !nsec && ( + {usesEncryption && (

{t( - '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). Use Show key and your encryption password to reveal the original nsec for backup.' )}

)} @@ -67,26 +171,56 @@ export default function PrivateKeyRecoverySetting() {
- +
- -
- {showKey && ( + {showKey && displayedKey && (

{t('Do not share this with anyone. Anyone with this key can control your account.')}

-
{recoverableKey}
+
{displayedKey}
)}
+ +
+

+ {t( + 'After backing up, you can remove the key from this browser and sign in with a browser extension or bunker instead.' + )} +

+ +
+ + + + {t('Remove local private key?')} + + {t( + 'The private key will be deleted from this browser only. Make sure you have copied it first. This account will become read-only here until you log in again with an extension, bunker, or private key.' + )} + + + + {t('Cancel')} + {t('Remove local private key')} + + +
) } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 05cdc098..214d40d1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -547,12 +547,25 @@ export default { '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.', + 'This account uses an encrypted key (ncryptsec). Use Show key and your encryption password to reveal the original nsec for backup.': + 'This account uses an encrypted key (ncryptsec). Use Show key and your encryption password to reveal the original nsec for backup.', + 'Could not decrypt — check your password and try again.': + 'Could not decrypt — check your password and try again.', '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.', + 'Back up your private key now': 'Back up your private key now', + 'Your account was just created. Copy your nsec (or ncryptsec) below and store it somewhere safe — password manager, encrypted file, or paper offline. Anyone with this key controls your account.': + 'Your account was just created. Copy your nsec (or ncryptsec) below and store it somewhere safe — password manager, encrypted file, or paper offline. Anyone with this key controls your account.', + 'After backing up, you can remove the key from this browser and sign in with a browser extension or bunker instead.': + 'After backing up, you can remove the key from this browser and sign in with a browser extension or bunker instead.', + 'Remove local private key': 'Remove local private key', + 'Remove local private key?': 'Remove local private key?', + 'The private key will be deleted from this browser only. Make sure you have copied it first. This account will become read-only here until you log in again with an extension, bunker, or private key.': + 'The private key will be deleted from this browser only. Make sure you have copied it first. This account will become read-only here until you log in again with an extension, bunker, or private key.', + 'Local private key removed. This account is read-only here until you log in with an extension, bunker, or private key again.': + 'Local private key removed. This account is read-only here until you log in with an extension, bunker, or private key again.', 'Copy npub': 'Copy npub', npub: 'npub', Edit: 'Edit', diff --git a/src/lib/new-user-template-broadcast.ts b/src/lib/new-user-template-broadcast.ts new file mode 100644 index 00000000..105fd7f4 --- /dev/null +++ b/src/lib/new-user-template-broadcast.ts @@ -0,0 +1,122 @@ +import { ExtendedKind, PROFILE_RELAY_URLS } from '@/constants' +import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' +import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' +import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' +import { collectWriteOutboxUrlsFromRelayList } from '@/lib/viewer-write-outboxes' +import logger from '@/lib/logger' +import client from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' +import type { TRelayList } from '@/types' +import { Event, kinds } from 'nostr-tools' + +const BROADCAST_PENDING_KEY = 'imwaldNewUserTemplateBroadcastPending' + +export const NEW_USER_TEMPLATE_BROADCAST_INTERVAL_MS = 5000 + +/** Replaceable kinds created during one-click signup, in publish order. */ +export const NEW_USER_TEMPLATE_BROADCAST_KINDS = [ + kinds.RelayList, + ExtendedKind.HTTP_RELAY_LIST, + ExtendedKind.FAVORITE_RELAYS, + kinds.Metadata, + 10015, + kinds.Contacts, + kinds.Mutelist +] as const + +const broadcastScheduledOrRunning = new Set() + +export function markNewUserTemplateBroadcastPending(pubkey: string): void { + if (typeof sessionStorage === 'undefined') return + sessionStorage.setItem(BROADCAST_PENDING_KEY, pubkey) +} + +function consumeBroadcastPending(pubkey: string): boolean { + if (typeof sessionStorage === 'undefined') return false + if (sessionStorage.getItem(BROADCAST_PENDING_KEY) !== pubkey) return false + sessionStorage.removeItem(BROADCAST_PENDING_KEY) + return true +} + +/** Write outboxes from the stored template plus profile index relays where the kind allows it. */ +export function newUserTemplatePublishRelays(kind: number, relayList: TRelayList): string[] { + const write = collectWriteOutboxUrlsFromRelayList(relayList) + const merged = + kind === kinds.Metadata || kind === kinds.RelayList + ? dedupeNormalizeRelayUrlsOrdered([...write, ...PROFILE_RELAY_URLS]) + : write + return filterRelaysForEventPublish(merged, kind) +} + +async function loadRelayListForPublish(pubkey: string): Promise { + const peeked = await client.peekRelayListFromStorage(pubkey) + if (peeked.write.length > 0 || peeked.httpWrite.length > 0) { + return peeked + } + const [relayListEvent, httpRelayListEvent] = await Promise.all([ + indexedDb.getReplaceableEvent(pubkey, kinds.RelayList), + indexedDb.getReplaceableEvent(pubkey, ExtendedKind.HTTP_RELAY_LIST) + ]) + const emptyHttp = { + httpRead: [] as string[], + httpWrite: [] as string[], + httpOriginalRelays: [] as TRelayList['httpOriginalRelays'] + } + let base: TRelayList = relayListEvent + ? getRelayListFromEvent(relayListEvent, []) + : { write: [], read: [], originalRelays: [], ...emptyHttp } + if (httpRelayListEvent) { + const http = getHttpRelayListFromEvent(httpRelayListEvent, []) + base = { + ...base, + httpRead: http.httpRead, + httpWrite: http.httpWrite, + httpOriginalRelays: http.httpOriginalRelays + } + } + return base +} + +async function broadcastNewUserTemplateFromStorage(pubkey: string): Promise { + const relayList = await loadRelayListForPublish(pubkey) + for (let i = 0; i < NEW_USER_TEMPLATE_BROADCAST_KINDS.length; i++) { + const kind = NEW_USER_TEMPLATE_BROADCAST_KINDS[i] + const event = (await indexedDb.getReplaceableEvent(pubkey, kind)) as Event | undefined + if (!event) continue + const relays = newUserTemplatePublishRelays(kind, relayList) + if (relays.length === 0) continue + try { + await client.publishEvent(relays, event, { + skipOutboxRetry: true, + publishBatchLabel: 'new user template broadcast' + }) + } catch (error) { + logger.warn('[newUserTemplateBroadcast] publish failed', { kind, error }) + } + if (i < NEW_USER_TEMPLATE_BROADCAST_KINDS.length - 1) { + await new Promise((resolve) => setTimeout(resolve, NEW_USER_TEMPLATE_BROADCAST_INTERVAL_MS)) + } + } +} + +/** + * After the user dismisses the backup banner or leaves cache settings, broadcast locally stored + * template events to their write outboxes and profile relays (5s between events). + */ +export function requestNewUserTemplateBroadcast(pubkey: string): void { + if (!pubkey || broadcastScheduledOrRunning.has(pubkey)) return + if (typeof sessionStorage === 'undefined') return + if (sessionStorage.getItem(BROADCAST_PENDING_KEY) !== pubkey) return + + broadcastScheduledOrRunning.add(pubkey) + void (async () => { + try { + if (!consumeBroadcastPending(pubkey)) return + await broadcastNewUserTemplateFromStorage(pubkey) + } catch (error) { + logger.error('[newUserTemplateBroadcast] failed', { error }) + } finally { + broadcastScheduledOrRunning.delete(pubkey) + } + })() +} diff --git a/src/lib/new-user-template.test.ts b/src/lib/new-user-template.test.ts index 6ae38832..dd651a4c 100644 --- a/src/lib/new-user-template.test.ts +++ b/src/lib/new-user-template.test.ts @@ -1,16 +1,33 @@ 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' +import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' +import { NEW_USER_HTTP_RELAY_URL, buildNewUserTemplateDrafts, newUserProfileDisplayName, newUserProfileName, newUserProfileSuffix } from '@/lib/new-user-template' +import { newUserTemplatePublishRelays } from '@/lib/new-user-template-broadcast' +import { normalizeAnyRelayUrl } from '@/lib/url' +import type { TRelayList } from '@/types' const TEST_PUBKEY = 'a'.repeat(63) + 'b' +function relayKey(url: string): string { + return (normalizeAnyRelayUrl(url) || url).toLowerCase() +} + +function expectRelayKeys(actual: string[], expected: string[]) { + const actualKeys = new Set(actual.map(relayKey)) + for (const url of expected) { + expect(actualKeys.has(relayKey(url))).toBe(true) + } +} + +const templateRelayList = (): TRelayList => ({ + write: [...FAST_WRITE_RELAY_URLS], + read: [...FAST_READ_RELAY_URLS], + originalRelays: [], + httpRead: [], + httpWrite: [NEW_USER_HTTP_RELAY_URL], + httpOriginalRelays: [] +}) + describe('newUserProfileSuffix', () => { it('returns a number between 1000 and 9999', () => { const suffix = newUserProfileSuffix(TEST_PUBKEY) @@ -57,7 +74,16 @@ describe('buildNewUserTemplateDrafts', () => { 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]) + expect(topics).toEqual([ + 'art', + 'music', + 'news', + 'foodstr', + 'coffeechain', + 'travel', + 'grownostr', + 'plebchain' + ]) }) it('builds empty follow and mute lists', () => { @@ -67,3 +93,23 @@ describe('buildNewUserTemplateDrafts', () => { expect(drafts.muteList.tags).toHaveLength(0) }) }) + +describe('newUserTemplatePublishRelays', () => { + const relayList = templateRelayList() + + it('uses template write outboxes only for list kinds', () => { + const targets = newUserTemplatePublishRelays(10015, relayList) + expectRelayKeys(targets, [...FAST_WRITE_RELAY_URLS, NEW_USER_HTTP_RELAY_URL]) + const profileOnlyUrls = PROFILE_RELAY_URLS.filter((u) => !FAST_WRITE_RELAY_URLS.includes(u)) + for (const profileUrl of profileOnlyUrls) { + expect(targets.map(relayKey)).not.toContain(relayKey(profileUrl)) + } + }) + + it('adds profile relays for kind 0 and 10002', () => { + const profileTargets = newUserTemplatePublishRelays(kinds.Metadata, relayList) + expectRelayKeys(profileTargets, [...FAST_WRITE_RELAY_URLS, NEW_USER_HTTP_RELAY_URL, ...PROFILE_RELAY_URLS]) + const relayListTargets = newUserTemplatePublishRelays(kinds.RelayList, relayList) + expectRelayKeys(relayListTargets, [...FAST_WRITE_RELAY_URLS, NEW_USER_HTTP_RELAY_URL, ...PROFILE_RELAY_URLS]) + }) +}) diff --git a/src/lib/post-signup-backup-prompt.ts b/src/lib/post-signup-backup-prompt.ts new file mode 100644 index 00000000..cb18387d --- /dev/null +++ b/src/lib/post-signup-backup-prompt.ts @@ -0,0 +1,50 @@ +const POST_SIGNUP_NAV_KEY = 'imwaldPostSignupBackupNav' +const NEW_USER_BACKUP_BANNER_KEY = 'imwaldNewUserBackupBanner' +const SKIP_NETWORK_HYDRATE_KEY = 'imwaldNewUserSkipNetworkHydrate' + +export function schedulePostSignupBackupPrompt(pubkey: string): void { + if (typeof sessionStorage === 'undefined') return + sessionStorage.setItem(POST_SIGNUP_NAV_KEY, pubkey) +} + +/** Returns true when this pubkey had a pending post-signup nav (and clears it). */ +export function consumePostSignupBackupPrompt(pubkey: string): boolean { + if (typeof sessionStorage === 'undefined') return false + const pending = sessionStorage.getItem(POST_SIGNUP_NAV_KEY) + if (pending !== pubkey) return false + sessionStorage.removeItem(POST_SIGNUP_NAV_KEY) + return true +} + +export function showNewUserBackupBanner(): void { + if (typeof sessionStorage === 'undefined') return + sessionStorage.setItem(NEW_USER_BACKUP_BANNER_KEY, '1') +} + +export function isNewUserBackupBannerVisible(): boolean { + if (typeof sessionStorage === 'undefined') return false + return sessionStorage.getItem(NEW_USER_BACKUP_BANNER_KEY) === '1' +} + +export function dismissNewUserBackupBanner(): void { + if (typeof sessionStorage === 'undefined') return + sessionStorage.removeItem(NEW_USER_BACKUP_BANNER_KEY) +} + +/** Skip heavy network hydrate while local template is written and relays publish in background. */ +export function markFreshSignupSkipNetworkHydrate(pubkey: string): void { + if (typeof sessionStorage === 'undefined') return + sessionStorage.setItem(SKIP_NETWORK_HYDRATE_KEY, pubkey) +} + +export function shouldSkipNetworkHydrateForFreshSignup(pubkey: string): boolean { + if (typeof sessionStorage === 'undefined') return false + return sessionStorage.getItem(SKIP_NETWORK_HYDRATE_KEY) === pubkey +} + +export function clearFreshSignupSkipNetworkHydrate(pubkey: string): void { + if (typeof sessionStorage === 'undefined') return + if (sessionStorage.getItem(SKIP_NETWORK_HYDRATE_KEY) === pubkey) { + sessionStorage.removeItem(SKIP_NETWORK_HYDRATE_KEY) + } +} diff --git a/src/pages/secondary/CacheSettingsPage/index.tsx b/src/pages/secondary/CacheSettingsPage/index.tsx index d71d628c..1670c181 100644 --- a/src/pages/secondary/CacheSettingsPage/index.tsx +++ b/src/pages/secondary/CacheSettingsPage/index.tsx @@ -4,17 +4,40 @@ import EventArchiveCacheSettings from '@/components/EventArchiveCacheSettings' import PrivateKeyRecoverySetting from '@/components/PrivateKeyRecoverySetting' import { RefreshButton } from '@/components/RefreshButton' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { isNewUserBackupBannerVisible } from '@/lib/post-signup-backup-prompt' +import { requestNewUserTemplateBroadcast } from '@/lib/new-user-template-broadcast' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' -import { forwardRef, useCallback, useEffect, useState } from 'react' +import { useNostr } from '@/providers/NostrProvider' +import { TPageRef } from '@/types' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -const CacheSettingsPage = forwardRef( - ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { +const CacheSettingsPage = forwardRef( + ({ index, hideTitlebar = false }, ref) => { const { t } = useTranslation() + const { pubkey } = useNostr() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() + const layoutRef = useRef(null) const [contentKey, setContentKey] = useState(0) const bump = useCallback(() => setContentKey((k) => k + 1), []) + useImperativeHandle( + ref, + () => ({ + scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), + refresh: bump + }), + [bump] + ) + + useEffect(() => { + if (!isNewUserBackupBannerVisible()) return + const scrollToTop = () => layoutRef.current?.scrollToTop('instant') + scrollToTop() + const timer = window.setTimeout(scrollToTop, 100) + return () => window.clearTimeout(timer) + }, []) + useEffect(() => { if (!hideTitlebar) { registerPrimaryPanelRefresh(null) @@ -24,9 +47,15 @@ const CacheSettingsPage = forwardRef( return () => registerPrimaryPanelRefresh(null) }, [hideTitlebar, registerPrimaryPanelRefresh, bump]) + useEffect(() => { + return () => { + if (pubkey) requestNewUserTemplateBroadcast(pubkey) + } + }, [pubkey]) + return ( } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index cf376bab..51eb3a32 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -6,7 +6,6 @@ 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, @@ -19,7 +18,17 @@ import { applyImwaldAttributionTags, createDeletionRequestDraftEvent } from '@/lib/draft-event' -import { buildNewUserTemplateDrafts } from '@/lib/new-user-template' +import { + TNewUserTemplateDrafts, + buildNewUserTemplateDrafts +} from '@/lib/new-user-template' +import { markNewUserTemplateBroadcastPending } from '@/lib/new-user-template-broadcast' +import { + clearFreshSignupSkipNetworkHydrate, + markFreshSignupSkipNetworkHydrate, + schedulePostSignupBackupPrompt, + shouldSkipNetworkHydrateForFreshSignup +} from '@/lib/post-signup-backup-prompt' import { getLatestEvent, minePow } from '@/lib/event' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { @@ -160,6 +169,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { /** Last account pubkey for which we cleared session UI; avoids nulling relay/profile on same-account rehydrate. */ const lastNetworkHydrateAccountPubkeyRef = useRef(null) const manualNetworkHydrateResolveRef = useRef<(() => void) | null>(null) + /** Prevents duplicate new-user sign/publish when login or StrictMode fires twice. */ + const newUserSetupInFlightRef = useRef(new Set()) const [accountNetworkHydrateBump, setAccountNetworkHydrateBump] = useState(0) /** * Bumped by {@link switchAccount} after it persists the intended target to storage following @@ -424,11 +435,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const lastNetworkHydrateAt = storage.getAccountNetworkHydrateAt(account.pubkey) const hasLocalRelayAndProfile = !!storedRelayListEvent && !!storedProfileEvent + const freshSignupSkipNetwork = shouldSkipNetworkHydrateForFreshSignup(account.pubkey) const skipNetworkHydrate = !userForcedAccountNetworkHydrate && - hasLocalRelayAndProfile && - typeof lastNetworkHydrateAt === 'number' && - Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS + (freshSignupSkipNetwork || + (hasLocalRelayAndProfile && + typeof lastNetworkHydrateAt === 'number' && + Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS)) if (!skipNetworkHydrate) { // Fetch RSS feed list from relays if cache is missing or stale (older than 1 hour) @@ -798,6 +811,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } else { logger.debug('[NostrProvider] Skipped network hydrate (within min interval); IndexedDB cache only', { pubkeySlice: account.pubkey.slice(0, 12), + freshSignupSkipNetwork, lastNetworkHydrateAt, ageMs: Date.now() - (lastNetworkHydrateAt ?? 0) }) @@ -805,7 +819,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { client.updateRelayListCache(storedRelayListEvent) } void client.runSessionPrewarm({ pubkey: account.pubkey, signal: controller.signal }) - if (!storedFollowListEvent) { + if (!storedFollowListEvent && !freshSignupSkipNetwork) { const trySetFollowListSkip = (evt: Event) => { if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return indexedDb @@ -1109,6 +1123,32 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } + const discardLocalPrivateKey = () => { + if (!account?.pubkey) { + throw new Error('Not logged in') + } + const stored = storage.findAccount(account) + if (!stored || (stored.signerType !== 'nsec' && stored.signerType !== 'ncryptsec')) { + throw new Error('No local private key stored for this account') + } + storage.removeAccount(stored) + const npub = nip19.npubEncode(stored.pubkey) + const readOnlyAccount: TAccount = { + pubkey: stored.pubkey, + signerType: 'npub', + npub + } + const newAccounts = storage.addAccount(readOnlyAccount) + storage.switchAccount(readOnlyAccount) + setAccounts(newAccounts) + setAccount({ pubkey: stored.pubkey, signerType: 'npub' }) + setNsec(null) + setNcryptsec(null) + const npubSigner = new NpubSigner() + npubSigner.login(npub) + setSigner(npubSigner) + } + const switchAccount = async (act: TAccountPointer | null): Promise => { intentionalNip07ReadOnlyPubkeyRef.current = null if (!act) { @@ -1164,14 +1204,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { throw new Error('invalid nsec or hex') } const pubkey = nsecSigner.login(privkey) - if (password) { - const ncryptsec = nip49.encrypt(privkey, password) - login(nsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec }) - } else { - login(nsecSigner, { pubkey, signerType: 'nsec', nsec: nip19.nsecEncode(privkey) }) - } + const act: TAccount = password + ? { pubkey, signerType: 'ncryptsec', ncryptsec: nip49.encrypt(privkey, password) } + : { pubkey, signerType: 'nsec', nsec: nip19.nsecEncode(privkey) } + + let signedTemplate: Record | null = null if (needSetup) { - setupNewUser(nsecSigner) + markFreshSignupSkipNetworkHydrate(pubkey) + signedTemplate = await persistNewUserTemplateLocally(nsecSigner, pubkey) + } + + login(nsecSigner, act) + if (act.nsec) setNsec(act.nsec) + if (act.ncryptsec) setNcryptsec(act.ncryptsec) + + if (needSetup && signedTemplate) { + markNewUserTemplateBroadcastPending(pubkey) + schedulePostSignupBackupPrompt(pubkey) + storage.setAccountNetworkHydrateAt(pubkey, Date.now()) + clearFreshSignupSkipNetworkHydrate(pubkey) } return pubkey } @@ -1893,13 +1944,17 @@ 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])] + const persistNewUserTemplateLocally = async ( + signer: ISigner, + pubkey: string + ): Promise> => { + if (newUserSetupInFlightRef.current.has(pubkey)) { + throw new Error('New user setup already in progress') + } + newUserSetupInFlightRef.current.add(pubkey) 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)) { @@ -1908,41 +1963,37 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { 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) + const signed = { + profile: await signDraft(drafts.profile), + favoriteRelays: await signDraft(drafts.favoriteRelays), + relayList: await signDraft(drafts.relayList), + httpRelayList: await signDraft(drafts.httpRelayList), + interestList: await signDraft(drafts.interestList), + followList: await signDraft(drafts.followList), + muteList: await signDraft(drafts.muteList) + } await Promise.all([ - updateProfileEvent(profileEvent), - updateFavoriteRelaysEvent(favoriteRelaysEvent), - updateRelayListEvent(relayListEvent), - updateHttpRelayListEvent(httpRelayListEvent), - updateInterestListEvent(interestListEvent), - updateFollowListEvent(followListEvent), - updateMuteListEvent(muteListEvent, []) + indexedDb.putReplaceableEvent(signed.profile), + indexedDb.putReplaceableEvent(signed.favoriteRelays), + indexedDb.putReplaceableEvent(signed.relayList), + indexedDb.putReplaceableEvent(signed.httpRelayList), + indexedDb.putReplaceableEvent(signed.interestList), + indexedDb.putReplaceableEvent(signed.followList), + indexedDb.putReplaceableEvent(signed.muteList) ]) - await Promise.allSettled( - [ - profileEvent, - favoriteRelaysEvent, - relayListEvent, - httpRelayListEvent, - interestListEvent, - followListEvent, - muteListEvent - ].map((event) => client.publishEvent(bootstrapRelays, event)) - ) + client.updateRelayListCache(signed.relayList) + void client.updateFollowListCache(signed.followList).catch(() => {}) + void replaceableEventService.updateReplaceableEventCache(signed.profile).catch(() => {}) - toast.success( - t('Account created — customize profile and relays in Settings.') - ) + return signed } catch (error) { - logger.error('[setupNewUser] failed', { error }) + clearFreshSignupSkipNetworkHydrate(pubkey) + logger.error('[setupNewUser] local persist failed', { error }) + throw error + } finally { + newUserSetupInFlightRef.current.delete(pubkey) } } @@ -1972,6 +2023,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const startLogin = useCallback(() => setOpenLoginDialog(true), []) const removeAccountStable = useEventCallback(removeAccount) + const discardLocalPrivateKeyStable = useEventCallback(discardLocalPrivateKey) const switchAccountStable = useEventCallback(switchAccount) const nsecLoginStable = useEventCallback(nsecLogin) const ncryptsecLoginStable = useEventCallback(ncryptsecLogin) @@ -2029,6 +2081,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { nostrConnectionLogin: nostrConnectionLoginStable, npubLogin: npubLoginStable, removeAccount: removeAccountStable, + discardLocalPrivateKey: discardLocalPrivateKeyStable, publish: publishStable, attemptDelete: attemptDeleteStable, signHttpAuth: signHttpAuthStable, @@ -2080,6 +2133,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { profileEvent, publishStable, relayList, + discardLocalPrivateKeyStable, removeAccountStable, requestAccountNetworkHydrate, rssFeedListEvent, diff --git a/src/providers/nostr-context.tsx b/src/providers/nostr-context.tsx index d8d6cd64..496eeb4b 100644 --- a/src/providers/nostr-context.tsx +++ b/src/providers/nostr-context.tsx @@ -44,6 +44,8 @@ export type TNostrContext = { nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise npubLogin(npub: string): Promise removeAccount: (account: TAccountPointer) => void + /** Remove locally stored nsec/ncryptsec; account becomes read-only npub until remote login. */ + discardLocalPrivateKey: () => void publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise attemptDelete: (targetEvent: Event) => Promise signHttpAuth: (url: string, method: string) => Promise