Browse Source

fix registering workflow

imwald
Silberengel 2 weeks ago
parent
commit
bdfb4fb96a
  1. 6
      src/PageManager.tsx
  2. 31
      src/components/CreateWalletGuideToast/index.tsx
  3. 3
      src/components/LogoutDialog/index.tsx
  4. 49
      src/components/PostSignupBackupRedirect/index.tsx
  5. 160
      src/components/PrivateKeyRecoverySetting/index.tsx
  6. 17
      src/i18n/locales/en.ts
  7. 122
      src/lib/new-user-template-broadcast.ts
  8. 64
      src/lib/new-user-template.test.ts
  9. 50
      src/lib/post-signup-backup-prompt.ts
  10. 37
      src/pages/secondary/CacheSettingsPage/index.tsx
  11. 144
      src/providers/NostrProvider/index.tsx
  12. 2
      src/providers/nostr-context.tsx

6
src/PageManager.tsx

@ -104,7 +104,7 @@ const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrima
const SidebarLazy = lazy(() => import('@/components/Sidebar')) const SidebarLazy = lazy(() => import('@/components/Sidebar'))
const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar')) const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar'))
const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog')) 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). */ /** 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')) const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage'))
@ -2414,7 +2414,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<TooManyRelaysAlertDialogLazy /> <TooManyRelaysAlertDialogLazy />
</Suspense> </Suspense>
<Suspense fallback={null}> <Suspense fallback={null}>
<CreateWalletGuideToastLazy /> <PostSignupBackupRedirectLazy />
</Suspense> </Suspense>
</NoteDrawerContext.Provider> </NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider> </PrimaryNoteViewContext.Provider>
@ -2549,7 +2549,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<TooManyRelaysAlertDialogLazy /> <TooManyRelaysAlertDialogLazy />
</Suspense> </Suspense>
<Suspense fallback={null}> <Suspense fallback={null}>
<CreateWalletGuideToastLazy /> <PostSignupBackupRedirectLazy />
</Suspense> </Suspense>
</NoteDrawerContext.Provider> </NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider> </PrimaryNoteViewContext.Provider>

31
src/components/CreateWalletGuideToast/index.tsx

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

3
src/components/LogoutDialog/index.tsx

@ -17,6 +17,7 @@ import {
DrawerHeader, DrawerHeader,
DrawerTitle DrawerTitle
} from '@/components/ui/drawer' } from '@/components/ui/drawer'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -31,10 +32,12 @@ export default function LogoutDialog({
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen = false } = useScreenSizeOptional() ?? {} const { isSmallScreen = false } = useScreenSizeOptional() ?? {}
const { account, switchAccount } = useNostr() const { account, switchAccount } = useNostr()
const { navigate } = usePrimaryPage()
const handleLogout = () => { const handleLogout = () => {
setOpen(false) setOpen(false)
void switchAccount(null) void switchAccount(null)
navigate('feed')
} }
if (isSmallScreen) { if (isSmallScreen) {

49
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<ReturnType<typeof setInterval> | 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
}

160
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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' 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 { pubkeyToNpub } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, Eye, EyeOff, KeyRound } from 'lucide-react' import storage from '@/services/local-storage.service'
import { useMemo, useState } from 'react' import { Check, Copy, Eye, EyeOff, KeyRound, Trash2, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' 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() { export default function PrivateKeyRecoverySetting() {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, nsec, ncryptsec } = useNostr() const { pubkey, account, nsec, ncryptsec, discardLocalPrivateKey } = useNostr()
const [showKey, setShowKey] = useState(false) const [showKey, setShowKey] = useState(false)
const [revealedNsec, setRevealedNsec] = useState<string | null>(null)
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false)
const [copiedNpub, setCopiedNpub] = useState(false) const [copiedNpub, setCopiedNpub] = useState(false)
const [copiedKey, setCopiedKey] = 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 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 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 ( return (
<section className="space-y-4"> <section className="space-y-4">
{showBackupBanner && (
<div className="rounded-lg border border-orange-500/50 bg-orange-500/10 p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-orange-600 dark:text-orange-400">
{t('Back up your private key now')}
</p>
<Button type="button" variant="ghost" size="icon" className="shrink-0 h-7 w-7" onClick={dismissBanner}>
<X className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{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.'
)}
</p>
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<KeyRound className="size-4 shrink-0 text-muted-foreground" /> <KeyRound className="size-4 shrink-0 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t('Private key recovery')}</h2> <h2 className="text-sm font-semibold">{t('Private key recovery')}</h2>
@ -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.' '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> </p>
{ncryptsec && !nsec && ( {usesEncryption && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {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.'
)} )}
</p> </p>
)} )}
@ -67,26 +171,56 @@ export default function PrivateKeyRecoverySetting() {
</div> </div>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('Copy private key')} ({keyLabel})</Label> <Label>
{t('Copy private key')} ({keyLabel})
</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={() => copyToClipboard(recoverableKey, 'key')}> <Button type="button" variant="secondary" onClick={() => copyKeyValue && copyToClipboard(copyKeyValue, 'key')}>
{copiedKey ? <Check className="mr-2 size-4" /> : <Copy className="mr-2 size-4" />} {copiedKey ? <Check className="mr-2 size-4" /> : <Copy className="mr-2 size-4" />}
{t('Copy private key')} {t('Copy private key')}
</Button> </Button>
<Button type="button" variant="outline" onClick={() => setShowKey((v) => !v)}> <Button type="button" variant="outline" onClick={handleToggleShowKey}>
{showKey ? <EyeOff className="mr-2 size-4" /> : <Eye className="mr-2 size-4" />} {showKey ? <EyeOff className="mr-2 size-4" /> : <Eye className="mr-2 size-4" />}
{showKey ? t('Hide key') : t('Show key')} {showKey ? t('Hide key') : t('Show key')}
</Button> </Button>
</div> </div>
{showKey && ( {showKey && displayedKey && (
<div className="rounded-md border bg-muted/40 p-3"> <div className="rounded-md border bg-muted/40 p-3">
<p className="text-xs text-orange-500 mb-2"> <p className="text-xs text-orange-500 mb-2">
{t('Do not share this with anyone. Anyone with this key can control your account.')} {t('Do not share this with anyone. Anyone with this key can control your account.')}
</p> </p>
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{recoverableKey}</pre> <pre className="text-xs font-mono whitespace-pre-wrap break-all">{displayedKey}</pre>
</div> </div>
)} )}
</div> </div>
<NcryptsecPasswordPrompt open={passwordPromptOpen} onResult={handleDecryptPassword} />
<div className="pt-2 border-t space-y-2">
<p className="text-sm text-muted-foreground">
{t(
'After backing up, you can remove the key from this browser and sign in with a browser extension or bunker instead.'
)}
</p>
<Button type="button" variant="destructive" onClick={() => setRemoveConfirmOpen(true)}>
<Trash2 className="mr-2 size-4" />
{t('Remove local private key')}
</Button>
</div>
<AlertDialog open={removeConfirmOpen} onOpenChange={setRemoveConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Remove local private key?')}</AlertDialogTitle>
<AlertDialogDescription>
{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.'
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleRemoveLocalKey}>{t('Remove local private key')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section> </section>
) )
} }

17
src/i18n/locales/en.ts

@ -547,12 +547,25 @@ export default {
'Private key recovery': 'Private key recovery', '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.':
'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). Use Show key and your encryption password to reveal the original nsec for backup.':
'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.',
'Could not decrypt — check your password and try again.':
'Could not decrypt — check your password and try again.',
'Show key': 'Show key', 'Show key': 'Show key',
'Hide key': 'Hide 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.':
'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', 'Copy npub': 'Copy npub',
npub: 'npub', npub: 'npub',
Edit: 'Edit', Edit: 'Edit',

122
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<string>()
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<TRelayList> {
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<void> {
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)
}
})()
}

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

@ -1,16 +1,33 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { import { NEW_USER_HTTP_RELAY_URL, buildNewUserTemplateDrafts, newUserProfileDisplayName, newUserProfileName, newUserProfileSuffix } from '@/lib/new-user-template'
NEW_USER_INTEREST_TOPICS, import { newUserTemplatePublishRelays } from '@/lib/new-user-template-broadcast'
buildNewUserTemplateDrafts, import { normalizeAnyRelayUrl } from '@/lib/url'
newUserProfileDisplayName, import type { TRelayList } from '@/types'
newUserProfileName,
newUserProfileSuffix
} from '@/lib/new-user-template'
const TEST_PUBKEY = 'a'.repeat(63) + 'b' 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', () => { describe('newUserProfileSuffix', () => {
it('returns a number between 1000 and 9999', () => { it('returns a number between 1000 and 9999', () => {
const suffix = newUserProfileSuffix(TEST_PUBKEY) const suffix = newUserProfileSuffix(TEST_PUBKEY)
@ -57,7 +74,16 @@ describe('buildNewUserTemplateDrafts', () => {
it('builds interest list with expected topics', () => { it('builds interest list with expected topics', () => {
expect(drafts.interestList.kind).toBe(10015) expect(drafts.interestList.kind).toBe(10015)
const topics = drafts.interestList.tags.filter((t) => t[0] === 't').map((t) => t[1]) 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', () => { it('builds empty follow and mute lists', () => {
@ -67,3 +93,23 @@ describe('buildNewUserTemplateDrafts', () => {
expect(drafts.muteList.tags).toHaveLength(0) 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])
})
})

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

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

@ -4,17 +4,40 @@ import EventArchiveCacheSettings from '@/components/EventArchiveCacheSettings'
import PrivateKeyRecoverySetting from '@/components/PrivateKeyRecoverySetting' 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 { isNewUserBackupBannerVisible } from '@/lib/post-signup-backup-prompt'
import { requestNewUserTemplateBroadcast } from '@/lib/new-user-template-broadcast'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' 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' import { useTranslation } from 'react-i18next'
const CacheSettingsPage = forwardRef( const CacheSettingsPage = forwardRef<TPageRef, { index?: number; hideTitlebar?: boolean }>(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const layoutRef = useRef<TPageRef>(null)
const [contentKey, setContentKey] = useState(0) const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), []) 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(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)
@ -24,9 +47,15 @@ const CacheSettingsPage = forwardRef(
return () => registerPrimaryPanelRefresh(null) return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bump]) }, [hideTitlebar, registerPrimaryPanelRefresh, bump])
useEffect(() => {
return () => {
if (pubkey) requestNewUserTemplateBroadcast(pubkey)
}
}, [pubkey])
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
ref={ref} ref={layoutRef}
index={index} index={index}
title={hideTitlebar ? undefined : t('Cache & offline storage')} title={hideTitlebar ? undefined : t('Cache & offline storage')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />} controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}

144
src/providers/NostrProvider/index.tsx

@ -6,7 +6,6 @@ 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,
@ -19,7 +18,17 @@ import {
applyImwaldAttributionTags, applyImwaldAttributionTags,
createDeletionRequestDraftEvent createDeletionRequestDraftEvent
} from '@/lib/draft-event' } 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 { getLatestEvent, minePow } from '@/lib/event'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { 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. */ /** Last account pubkey for which we cleared session UI; avoids nulling relay/profile on same-account rehydrate. */
const lastNetworkHydrateAccountPubkeyRef = useRef<string | null>(null) const lastNetworkHydrateAccountPubkeyRef = useRef<string | null>(null)
const manualNetworkHydrateResolveRef = useRef<(() => void) | null>(null) const manualNetworkHydrateResolveRef = useRef<(() => void) | null>(null)
/** Prevents duplicate new-user sign/publish when login or StrictMode fires twice. */
const newUserSetupInFlightRef = useRef(new Set<string>())
const [accountNetworkHydrateBump, setAccountNetworkHydrateBump] = useState(0) const [accountNetworkHydrateBump, setAccountNetworkHydrateBump] = useState(0)
/** /**
* Bumped by {@link switchAccount} after it persists the intended target to storage following * 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 lastNetworkHydrateAt = storage.getAccountNetworkHydrateAt(account.pubkey)
const hasLocalRelayAndProfile = !!storedRelayListEvent && !!storedProfileEvent const hasLocalRelayAndProfile = !!storedRelayListEvent && !!storedProfileEvent
const freshSignupSkipNetwork = shouldSkipNetworkHydrateForFreshSignup(account.pubkey)
const skipNetworkHydrate = const skipNetworkHydrate =
!userForcedAccountNetworkHydrate && !userForcedAccountNetworkHydrate &&
hasLocalRelayAndProfile && (freshSignupSkipNetwork ||
(hasLocalRelayAndProfile &&
typeof lastNetworkHydrateAt === 'number' && typeof lastNetworkHydrateAt === 'number' &&
Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS))
if (!skipNetworkHydrate) { if (!skipNetworkHydrate) {
// Fetch RSS feed list from relays if cache is missing or stale (older than 1 hour) // 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 { } else {
logger.debug('[NostrProvider] Skipped network hydrate (within min interval); IndexedDB cache only', { logger.debug('[NostrProvider] Skipped network hydrate (within min interval); IndexedDB cache only', {
pubkeySlice: account.pubkey.slice(0, 12), pubkeySlice: account.pubkey.slice(0, 12),
freshSignupSkipNetwork,
lastNetworkHydrateAt, lastNetworkHydrateAt,
ageMs: Date.now() - (lastNetworkHydrateAt ?? 0) ageMs: Date.now() - (lastNetworkHydrateAt ?? 0)
}) })
@ -805,7 +819,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
client.updateRelayListCache(storedRelayListEvent) client.updateRelayListCache(storedRelayListEvent)
} }
void client.runSessionPrewarm({ pubkey: account.pubkey, signal: controller.signal }) void client.runSessionPrewarm({ pubkey: account.pubkey, signal: controller.signal })
if (!storedFollowListEvent) { if (!storedFollowListEvent && !freshSignupSkipNetwork) {
const trySetFollowListSkip = (evt: Event) => { const trySetFollowListSkip = (evt: Event) => {
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return
indexedDb 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<string | null> => { const switchAccount = async (act: TAccountPointer | null): Promise<string | null> => {
intentionalNip07ReadOnlyPubkeyRef.current = null intentionalNip07ReadOnlyPubkeyRef.current = null
if (!act) { if (!act) {
@ -1164,14 +1204,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
throw new Error('invalid nsec or hex') throw new Error('invalid nsec or hex')
} }
const pubkey = nsecSigner.login(privkey) const pubkey = nsecSigner.login(privkey)
if (password) { const act: TAccount = password
const ncryptsec = nip49.encrypt(privkey, password) ? { pubkey, signerType: 'ncryptsec', ncryptsec: nip49.encrypt(privkey, password) }
login(nsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec }) : { pubkey, signerType: 'nsec', nsec: nip19.nsecEncode(privkey) }
} else {
login(nsecSigner, { pubkey, signerType: 'nsec', nsec: nip19.nsecEncode(privkey) }) let signedTemplate: Record<keyof TNewUserTemplateDrafts, VerifiedEvent> | null = null
}
if (needSetup) { 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 return pubkey
} }
@ -1893,13 +1944,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFavoriteRelaysEvent(stored) setFavoriteRelaysEvent(stored)
} }
const setupNewUser = async (signer: ISigner) => { const persistNewUserTemplateLocally = async (
const bootstrapRelays = [...new Set([...FAST_WRITE_RELAY_URLS, ...FAST_READ_RELAY_URLS])] signer: ISigner,
pubkey: string
): Promise<Record<keyof TNewUserTemplateDrafts, VerifiedEvent>> => {
if (newUserSetupInFlightRef.current.has(pubkey)) {
throw new Error('New user setup already in progress')
}
newUserSetupInFlightRef.current.add(pubkey)
try { try {
const pubkey = await signer.getPublicKey()
const drafts = buildNewUserTemplateDrafts(pubkey) const drafts = buildNewUserTemplateDrafts(pubkey)
const signDraft = async (draft: TDraftEvent) => { const signDraft = async (draft: TDraftEvent) => {
const event = await signer.signEvent(normalizeDraftEventTags(draft)) const event = await signer.signEvent(normalizeDraftEventTags(draft))
if (!validateEvent(event)) { if (!validateEvent(event)) {
@ -1908,41 +1963,37 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return event as VerifiedEvent return event as VerifiedEvent
} }
const profileEvent = await signDraft(drafts.profile) const signed = {
const favoriteRelaysEvent = await signDraft(drafts.favoriteRelays) profile: await signDraft(drafts.profile),
const relayListEvent = await signDraft(drafts.relayList) favoriteRelays: await signDraft(drafts.favoriteRelays),
const httpRelayListEvent = await signDraft(drafts.httpRelayList) relayList: await signDraft(drafts.relayList),
const interestListEvent = await signDraft(drafts.interestList) httpRelayList: await signDraft(drafts.httpRelayList),
const followListEvent = await signDraft(drafts.followList) interestList: await signDraft(drafts.interestList),
const muteListEvent = await signDraft(drafts.muteList) followList: await signDraft(drafts.followList),
muteList: await signDraft(drafts.muteList)
}
await Promise.all([ await Promise.all([
updateProfileEvent(profileEvent), indexedDb.putReplaceableEvent(signed.profile),
updateFavoriteRelaysEvent(favoriteRelaysEvent), indexedDb.putReplaceableEvent(signed.favoriteRelays),
updateRelayListEvent(relayListEvent), indexedDb.putReplaceableEvent(signed.relayList),
updateHttpRelayListEvent(httpRelayListEvent), indexedDb.putReplaceableEvent(signed.httpRelayList),
updateInterestListEvent(interestListEvent), indexedDb.putReplaceableEvent(signed.interestList),
updateFollowListEvent(followListEvent), indexedDb.putReplaceableEvent(signed.followList),
updateMuteListEvent(muteListEvent, []) indexedDb.putReplaceableEvent(signed.muteList)
]) ])
await Promise.allSettled( client.updateRelayListCache(signed.relayList)
[ void client.updateFollowListCache(signed.followList).catch(() => {})
profileEvent, void replaceableEventService.updateReplaceableEventCache(signed.profile).catch(() => {})
favoriteRelaysEvent,
relayListEvent,
httpRelayListEvent,
interestListEvent,
followListEvent,
muteListEvent
].map((event) => client.publishEvent(bootstrapRelays, event))
)
toast.success( return signed
t('Account created — customize profile and relays in Settings.')
)
} catch (error) { } 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 startLogin = useCallback(() => setOpenLoginDialog(true), [])
const removeAccountStable = useEventCallback(removeAccount) const removeAccountStable = useEventCallback(removeAccount)
const discardLocalPrivateKeyStable = useEventCallback(discardLocalPrivateKey)
const switchAccountStable = useEventCallback(switchAccount) const switchAccountStable = useEventCallback(switchAccount)
const nsecLoginStable = useEventCallback(nsecLogin) const nsecLoginStable = useEventCallback(nsecLogin)
const ncryptsecLoginStable = useEventCallback(ncryptsecLogin) const ncryptsecLoginStable = useEventCallback(ncryptsecLogin)
@ -2029,6 +2081,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
nostrConnectionLogin: nostrConnectionLoginStable, nostrConnectionLogin: nostrConnectionLoginStable,
npubLogin: npubLoginStable, npubLogin: npubLoginStable,
removeAccount: removeAccountStable, removeAccount: removeAccountStable,
discardLocalPrivateKey: discardLocalPrivateKeyStable,
publish: publishStable, publish: publishStable,
attemptDelete: attemptDeleteStable, attemptDelete: attemptDeleteStable,
signHttpAuth: signHttpAuthStable, signHttpAuth: signHttpAuthStable,
@ -2080,6 +2133,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
profileEvent, profileEvent,
publishStable, publishStable,
relayList, relayList,
discardLocalPrivateKeyStable,
removeAccountStable, removeAccountStable,
requestAccountNetworkHydrate, requestAccountNetworkHydrate,
rssFeedListEvent, rssFeedListEvent,

2
src/providers/nostr-context.tsx

@ -44,6 +44,8 @@ export type TNostrContext = {
nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string> nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string>
npubLogin(npub: string): Promise<string> npubLogin(npub: string): Promise<string>
removeAccount: (account: TAccountPointer) => void 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<Event> publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise<Event>
attemptDelete: (targetEvent: Event) => Promise<void> attemptDelete: (targetEvent: Event) => Promise<void>
signHttpAuth: (url: string, method: string) => Promise<string> signHttpAuth: (url: string, method: string) => Promise<string>

Loading…
Cancel
Save