You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

921 lines
32 KiB

import LoginDialog from '@/components/LoginDialog'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import {
createDeletionRequestDraftEvent,
createFollowListDraftEvent,
createMuteListDraftEvent,
createRelayListDraftEvent,
createSeenNotificationsAtDraftEvent
} from '@/lib/draft-event'
import {
getLatestEvent,
getReplaceableEventIdentifier,
minePow
} from '@/lib/event'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { normalizeUrl } from '@/lib/url'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service'
import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service'
import noteStatsService from '@/services/note-stats.service'
import {
ISigner,
TAccount,
TAccountPointer,
TDraftEvent,
TProfile,
TPublishOptions,
TRelayList
} from '@/types'
import { hexToBytes } from '@noble/hashes/utils'
import dayjs from 'dayjs'
import { Event, kinds, VerifiedEvent, validateEvent } from 'nostr-tools'
import * as nip19 from 'nostr-tools/nip19'
import * as nip49 from 'nostr-tools/nip49'
import { createContext, useContext, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useDeletedEvent } from '../DeletedEventProvider'
import { BunkerSigner } from './bunker.signer'
import { Nip07Signer } from './nip-07.signer'
import { NostrConnectionSigner } from './nostrConnection.signer'
import { NpubSigner } from './npub.signer'
import { NsecSigner } from './nsec.signer'
type TNostrContext = {
isInitialized: boolean
pubkey: string | null
profile: TProfile | null
profileEvent: Event | null
relayList: TRelayList | null
followListEvent: Event | null
muteListEvent: Event | null
bookmarkListEvent: Event | null
interestListEvent: Event | null
favoriteRelaysEvent: Event | null
userEmojiListEvent: Event | null
notificationsSeenAt: number
account: TAccountPointer | null
accounts: TAccountPointer[]
nsec: string | null
ncryptsec: string | null
switchAccount: (account: TAccountPointer | null) => Promise<void>
nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise<string>
ncryptsecLogin: (ncryptsec: string) => Promise<string>
nip07Login: () => Promise<string>
bunkerLogin: (bunker: string) => Promise<string>
nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string>
npubLogin(npub: string): Promise<string>
removeAccount: (account: TAccountPointer) => void
/**
* Default publish the event to current relays, user's write relays and additional relays
*/
publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise<Event>
attemptDelete: (targetEvent: Event) => Promise<void>
signHttpAuth: (url: string, method: string) => Promise<string>
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
startLogin: () => void
checkLogin: <T>(cb?: () => T) => Promise<T | void>
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
updateProfileEvent: (profileEvent: Event) => Promise<void>
updateFollowListEvent: (followListEvent: Event) => Promise<void>
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
updateInterestListEvent: (interestListEvent: Event) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
}
const NostrContext = createContext<TNostrContext | undefined>(undefined)
const lastPublishedSeenNotificationsAtEventAtMap = new Map<string, number>()
export const useNostr = () => {
const context = useContext(NostrContext)
if (!context) {
throw new Error('useNostr must be used within a NostrProvider')
}
return context
}
export function NostrProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const { addDeletedEvent } = useDeletedEvent()
const [accounts, setAccounts] = useState<TAccountPointer[]>(
storage.getAccounts().map((act) => ({ pubkey: act.pubkey, signerType: act.signerType }))
)
const [account, setAccount] = useState<TAccountPointer | null>(null)
const [nsec, setNsec] = useState<string | null>(null)
const [ncryptsec, setNcryptsec] = useState<string | null>(null)
const [signer, setSigner] = useState<ISigner | null>(null)
const [openLoginDialog, setOpenLoginDialog] = useState(false)
const [profile, setProfile] = useState<TProfile | null>(null)
// Cleanup on page unload to prevent extension UI issues
useEffect(() => {
const handleBeforeUnload = () => {
// Try to clean up any pending operations
if (signer && 'disconnect' in signer) {
try {
(signer as any).disconnect()
} catch (error) {
console.warn('Failed to disconnect signer:', error)
}
}
}
const handleUnload = () => {
// Additional cleanup for extensions that might leave UI elements
try {
// Clear any pending timeouts or intervals
if (window.nostr && typeof window.nostr === 'object') {
// Some extensions might have cleanup methods
if ('cleanup' in window.nostr && typeof window.nostr.cleanup === 'function') {
window.nostr.cleanup()
}
}
} catch (error) {
console.warn('Extension cleanup failed:', error)
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('unload', handleUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('unload', handleUnload)
}
}, [signer])
const [profileEvent, setProfileEvent] = useState<Event | null>(null)
const [relayList, setRelayList] = useState<TRelayList | null>(null)
const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
const [muteListEvent, setMuteListEvent] = useState<Event | null>(null)
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
const [interestListEvent, setInterestListEvent] = useState<Event | null>(null)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
const [isInitialized, setIsInitialized] = useState(false)
useEffect(() => {
const init = async () => {
if (hasNostrLoginHash()) {
return await loginByNostrLoginHash()
}
const accounts = storage.getAccounts()
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
if (!act) return
await loginWithAccountPointer(act)
}
init().then(() => {
setIsInitialized(true)
})
const handleHashChange = () => {
if (hasNostrLoginHash()) {
loginByNostrLoginHash()
}
}
window.addEventListener('hashchange', handleHashChange)
return () => {
window.removeEventListener('hashchange', handleHashChange)
}
}, [])
useEffect(() => {
const init = async () => {
setRelayList(null)
setProfile(null)
setProfileEvent(null)
setNsec(null)
setFavoriteRelaysEvent(null)
setFollowListEvent(null)
setMuteListEvent(null)
setBookmarkListEvent(null)
setNotificationsSeenAt(-1)
if (!account) {
return
}
const controller = new AbortController()
const storedNsec = storage.getAccountNsec(account.pubkey)
if (storedNsec) {
setNsec(storedNsec)
} else {
setNsec(null)
}
const storedNcryptsec = storage.getAccountNcryptsec(account.pubkey)
if (storedNcryptsec) {
setNcryptsec(storedNcryptsec)
} else {
setNcryptsec(null)
}
const storedNotificationsSeenAt = storage.getLastReadNotificationTime(account.pubkey)
const [
storedRelayListEvent,
storedProfileEvent,
storedFollowListEvent,
storedMuteListEvent,
storedBookmarkListEvent,
storedFavoriteRelaysEvent,
storedUserEmojiListEvent
] = await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist),
indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS),
indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList)
])
if (storedRelayListEvent) {
setRelayList(getRelayListFromEvent(storedRelayListEvent))
}
if (storedProfileEvent) {
setProfileEvent(storedProfileEvent)
setProfile(getProfileFromEvent(storedProfileEvent))
}
if (storedFollowListEvent) {
setFollowListEvent(storedFollowListEvent)
}
if (storedMuteListEvent) {
setMuteListEvent(storedMuteListEvent)
}
if (storedBookmarkListEvent) {
setBookmarkListEvent(storedBookmarkListEvent)
}
if (storedFavoriteRelaysEvent) {
setFavoriteRelaysEvent(storedFavoriteRelaysEvent)
}
if (storedUserEmojiListEvent) {
setUserEmojiListEvent(storedUserEmojiListEvent)
}
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
kinds: [kinds.RelayList],
authors: [account.pubkey]
})
const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
const relayList = getRelayListFromEvent(relayListEvent)
if (relayListEvent) {
client.updateRelayListCache(relayListEvent)
await indexedDb.putReplaceableEvent(relayListEvent)
}
setRelayList(relayList)
const normalizedRelays = [
...relayList.write.map(url => normalizeUrl(url) || url),
...BIG_RELAY_URLS.map(url => normalizeUrl(url) || url)
]
const fetchRelays = Array.from(new Set(normalizedRelays)).slice(0, 4)
const events = await client.fetchEvents(fetchRelays, [
{
kinds: [
kinds.Metadata,
kinds.Contacts,
kinds.Mutelist,
kinds.BookmarkList,
10015, // Interest list
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST,
kinds.UserEmojiList
],
authors: [account.pubkey]
},
{
kinds: [kinds.Application],
authors: [account.pubkey],
'#d': [ApplicationDataKey.NOTIFICATIONS_SEEN_AT]
}
])
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata)
const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts)
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList)
const interestListEvent = sortedEvents.find((e) => e.kind === 10015)
const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS)
const blossomServerListEvent = sortedEvents.find(
(e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
)
const userEmojiListEvent = sortedEvents.find((e) => e.kind === kinds.UserEmojiList)
const notificationsSeenAtEvent = sortedEvents.find(
(e) =>
e.kind === kinds.Application &&
getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT
)
if (profileEvent) {
const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
if (updatedProfileEvent.id === profileEvent.id) {
setProfileEvent(updatedProfileEvent)
setProfile(getProfileFromEvent(updatedProfileEvent))
}
} else if (!storedProfileEvent) {
setProfile({
pubkey: account.pubkey,
npub: pubkeyToNpub(account.pubkey) ?? '',
username: formatPubkey(account.pubkey)
})
}
if (followListEvent) {
const updatedFollowListEvent = await indexedDb.putReplaceableEvent(followListEvent)
if (updatedFollowListEvent.id === followListEvent.id) {
setFollowListEvent(followListEvent)
}
}
if (muteListEvent) {
const updatedMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent)
if (updatedMuteListEvent.id === muteListEvent.id) {
setMuteListEvent(muteListEvent)
}
}
if (bookmarkListEvent) {
const updateBookmarkListEvent = await indexedDb.putReplaceableEvent(bookmarkListEvent)
if (updateBookmarkListEvent.id === bookmarkListEvent.id) {
setBookmarkListEvent(bookmarkListEvent)
}
}
if (interestListEvent) {
const updatedInterestListEvent = await indexedDb.putReplaceableEvent(interestListEvent)
if (updatedInterestListEvent.id === interestListEvent.id) {
setInterestListEvent(interestListEvent)
}
}
if (favoriteRelaysEvent) {
const updatedFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
if (updatedFavoriteRelaysEvent.id === favoriteRelaysEvent.id) {
setFavoriteRelaysEvent(updatedFavoriteRelaysEvent)
}
}
if (blossomServerListEvent) {
await client.updateBlossomServerListEventCache(blossomServerListEvent)
}
if (userEmojiListEvent) {
const updatedUserEmojiListEvent = await indexedDb.putReplaceableEvent(userEmojiListEvent)
if (updatedUserEmojiListEvent.id === userEmojiListEvent.id) {
setUserEmojiListEvent(updatedUserEmojiListEvent)
}
}
const notificationsSeenAt = Math.max(
notificationsSeenAtEvent?.created_at ?? 0,
storedNotificationsSeenAt
)
setNotificationsSeenAt(notificationsSeenAt)
storage.setLastReadNotificationTime(account.pubkey, notificationsSeenAt)
client.initUserIndexFromFollowings(account.pubkey, controller.signal)
return controller
}
const promise = init()
return () => {
promise.then((controller) => {
controller?.abort()
})
}
}, [account])
useEffect(() => {
if (!account) return
const initInteractions = async () => {
const pubkey = account.pubkey
const relayList = await client.fetchRelayList(pubkey)
const events = await client.fetchEvents(relayList.write.slice(0, 4), [
{
authors: [pubkey],
kinds: [kinds.Reaction, kinds.Repost],
limit: 100
},
{
'#P': [pubkey],
kinds: [kinds.Zap],
limit: 100
}
])
noteStatsService.updateNoteStatsByEvents(events)
}
initInteractions()
}, [account])
useEffect(() => {
if (signer) {
client.signer = signer
} else {
client.signer = undefined
}
}, [signer])
useEffect(() => {
if (account) {
client.pubkey = account.pubkey
} else {
client.pubkey = undefined
}
}, [account])
useEffect(() => {
customEmojiService.init(userEmojiListEvent)
}, [userEmojiListEvent])
const hasNostrLoginHash = () => {
return window.location.hash && window.location.hash.startsWith('#nostr-login')
}
const loginByNostrLoginHash = async () => {
const credential = window.location.hash.replace('#nostr-login=', '')
const urlWithoutHash = window.location.href.split('#')[0]
history.replaceState(null, '', urlWithoutHash)
if (credential.startsWith('bunker://')) {
return await bunkerLogin(credential)
} else if (credential.startsWith('ncryptsec')) {
return await ncryptsecLogin(credential)
} else if (credential.startsWith('nsec')) {
return await nsecLogin(credential)
}
}
const login = (signer: ISigner, act: TAccount) => {
const newAccounts = storage.addAccount(act)
setAccounts(newAccounts)
storage.switchAccount(act)
setAccount({ pubkey: act.pubkey, signerType: act.signerType })
setSigner(signer)
return act.pubkey
}
const removeAccount = (act: TAccountPointer) => {
const newAccounts = storage.removeAccount(act)
setAccounts(newAccounts)
if (account?.pubkey === act.pubkey) {
setAccount(null)
setSigner(null)
}
}
const switchAccount = async (act: TAccountPointer | null) => {
if (!act) {
storage.switchAccount(null)
setAccount(null)
setSigner(null)
return
}
await loginWithAccountPointer(act)
}
const nsecLogin = async (nsecOrHex: string, password?: string, needSetup?: boolean) => {
const nsecSigner = new NsecSigner()
let privkey: Uint8Array
if (nsecOrHex.startsWith('nsec')) {
const { type, data } = nip19.decode(nsecOrHex)
if (type !== 'nsec') {
throw new Error('invalid nsec or hex')
}
privkey = data
} else if (/^[0-9a-fA-F]{64}$/.test(nsecOrHex)) {
privkey = hexToBytes(nsecOrHex)
} else {
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) })
}
if (needSetup) {
setupNewUser(nsecSigner)
}
return pubkey
}
const ncryptsecLogin = async (ncryptsec: string) => {
const password = prompt(t('Enter the password to decrypt your ncryptsec'))
if (!password) {
throw new Error('Password is required')
}
const privkey = nip49.decrypt(ncryptsec, password)
const browserNsecSigner = new NsecSigner()
const pubkey = browserNsecSigner.login(privkey)
return login(browserNsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec })
}
const npubLogin = async (npub: string) => {
const npubSigner = new NpubSigner()
const pubkey = npubSigner.login(npub)
return login(npubSigner, { pubkey, signerType: 'npub', npub })
}
const nip07Login = async () => {
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
const pubkey = await nip07Signer.getPublicKey()
if (!pubkey) {
throw new Error('You did not allow to access your pubkey')
}
return login(nip07Signer, { pubkey, signerType: 'nip-07' })
} catch (err) {
toast.error(t('Login failed') + ': ' + (err as Error).message)
throw err
}
}
const bunkerLogin = async (bunker: string) => {
const bunkerSigner = new BunkerSigner()
const pubkey = await bunkerSigner.login(bunker)
if (!pubkey) {
throw new Error('Invalid bunker')
}
const bunkerUrl = new URL(bunker)
bunkerUrl.searchParams.delete('secret')
return login(bunkerSigner, {
pubkey,
signerType: 'bunker',
bunker: bunkerUrl.toString(),
bunkerClientSecretKey: bunkerSigner.getClientSecretKey()
})
}
const nostrConnectionLogin = async (clientSecretKey: Uint8Array, connectionString: string) => {
const bunkerSigner = new NostrConnectionSigner(clientSecretKey, connectionString)
const loginResult = await bunkerSigner.login()
if (!loginResult.pubkey) {
throw new Error('Invalid bunker')
}
const bunkerUrl = new URL(loginResult.bunkerString!)
bunkerUrl.searchParams.delete('secret')
return login(bunkerSigner, {
pubkey: loginResult.pubkey,
signerType: 'bunker',
bunker: bunkerUrl.toString(),
bunkerClientSecretKey: bunkerSigner.getClientSecretKey()
})
}
const loginWithAccountPointer = async (act: TAccountPointer): Promise<string | null> => {
let account = storage.findAccount(act)
if (!account) {
return null
}
if (account.signerType === 'nsec' || account.signerType === 'browser-nsec') {
if (account.nsec) {
const browserNsecSigner = new NsecSigner()
browserNsecSigner.login(account.nsec)
// Migrate to nsec
if (account.signerType === 'browser-nsec') {
storage.removeAccount(account)
account = { ...account, signerType: 'nsec' }
storage.addAccount(account)
}
return login(browserNsecSigner, account)
}
} else if (account.signerType === 'ncryptsec') {
if (account.ncryptsec) {
const password = prompt(t('Enter the password to decrypt your ncryptsec'))
if (!password) {
return null
}
const privkey = nip49.decrypt(account.ncryptsec, password)
const browserNsecSigner = new NsecSigner()
browserNsecSigner.login(privkey)
return login(browserNsecSigner, account)
}
} else if (account.signerType === 'nip-07') {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
return login(nip07Signer, account)
} else if (account.signerType === 'bunker') {
if (account.bunker && account.bunkerClientSecretKey) {
const bunkerSigner = new BunkerSigner(account.bunkerClientSecretKey)
const pubkey = await bunkerSigner.login(account.bunker, false)
if (!pubkey) {
storage.removeAccount(account)
return null
}
if (pubkey !== account.pubkey) {
storage.removeAccount(account)
account = { ...account, pubkey }
storage.addAccount(account)
}
return login(bunkerSigner, account)
}
} else if (account.signerType === 'npub' && account.npub) {
const npubSigner = new NpubSigner()
const pubkey = npubSigner.login(account.npub)
if (!pubkey) {
storage.removeAccount(account)
return null
}
if (pubkey !== account.pubkey) {
storage.removeAccount(account)
account = { ...account, pubkey }
storage.addAccount(account)
}
return login(npubSigner, account)
}
storage.removeAccount(account)
return null
}
const setupNewUser = async (signer: ISigner) => {
await Promise.allSettled([
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createFollowListDraftEvent([]))),
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createMuteListDraftEvent([]))),
client.publishEvent(
BIG_RELAY_URLS,
await signer.signEvent(
createRelayListDraftEvent(BIG_RELAY_URLS.map((url) => ({ url, scope: 'both' })))
)
)
])
}
const signEvent = async (draftEvent: TDraftEvent) => {
// Add timeout to prevent hanging
const signEventWithTimeout = new Promise(async (resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Signing request timed out. Your Nostr extension may be waiting for authorization. Try closing this tab and restarting your browser to surface any pending authorization requests from your extension.'))
}, 30000) // 30 second timeout
try {
const event = await signer?.signEvent(draftEvent)
clearTimeout(timeout)
resolve(event)
} catch (error) {
clearTimeout(timeout)
reject(error)
}
})
const event = await signEventWithTimeout as VerifiedEvent
if (!event) {
throw new Error('sign event failed')
}
// Validate the event before publishing
const isValid = validateEvent(event)
if (!isValid) {
console.error('Event validation failed:', event)
throw new Error('Event validation failed - invalid signature or format. Please try logging in again.')
}
return event as VerifiedEvent
}
const publish = async (
draftEvent: TDraftEvent,
{ minPow = 0, ...options }: TPublishOptions = {}
) => {
if (!account || !signer || account.signerType === 'npub') {
throw new Error('You need to login first')
}
// Validate account state before publishing
if (!account.pubkey || account.pubkey.length !== 64) {
throw new Error('Invalid account state - pubkey is missing or invalid')
}
const draft = JSON.parse(JSON.stringify(draftEvent)) as TDraftEvent
let event: VerifiedEvent
if (minPow > 0) {
const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow)
event = await signEvent(unsignedEvent)
} else {
event = await signEvent(draft)
}
if (event.kind !== kinds.Application && event.pubkey !== account.pubkey) {
const eventAuthor = await client.fetchProfile(event.pubkey)
const result = confirm(
t(
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?',
{ eventAuthorName: eventAuthor?.username, currentUsername: profile?.username }
)
)
if (!result) {
throw new Error(t('Cancelled'))
}
}
const relays = await client.determineTargetRelays(event, options)
try {
const publishResult = await client.publishEvent(relays, event)
// Store relay status for display
if (publishResult.relayStatuses.length > 0) {
(event as any).relayStatuses = publishResult.relayStatuses
}
return event
} catch (error) {
// Check for authentication-related errors
if (error instanceof AggregateError && (error as any).relayStatuses) {
(event as any).relayStatuses = (error as any).relayStatuses
// Check if any relay returned an "invalid key" error
const invalidKeyErrors = (error as any).relayStatuses.filter(
(status: any) => status.error && status.error.includes('invalid key')
)
if (invalidKeyErrors.length > 0) {
throw new Error('Authentication failed - invalid key. Please try logging out and logging in again.')
}
}
// Re-throw the error so the UI can handle it appropriately
throw error
}
}
const attemptDelete = async (targetEvent: Event) => {
if (!signer) {
throw new Error(t('You need to login first'))
}
if (account?.pubkey !== targetEvent.pubkey) {
throw new Error(t('You can only delete your own notes'))
}
const deletionRequest = await signEvent(createDeletionRequestDraftEvent(targetEvent))
// Privacy: Only use user's own relays, never connect to "seen on" relays
const relays = await client.determineTargetRelays(targetEvent)
const result = await client.publishEvent(relays, deletionRequest)
addDeletedEvent(targetEvent)
// Show publishing feedback
if (result.relayStatuses) {
showPublishingFeedback(result, {
message: t('Deletion request sent'),
duration: 6000
})
} else {
showSimplePublishSuccess(t('Deletion request sent'))
}
}
const signHttpAuth = async (url: string, method: string, content = '') => {
const event = await signEvent({
content,
kind: kinds.HTTPAuth,
created_at: dayjs().unix(),
tags: [
['u', url],
['method', method]
]
})
return 'Nostr ' + btoa(JSON.stringify(event))
}
const nip04Encrypt = async (pubkey: string, plainText: string) => {
return signer?.nip04Encrypt(pubkey, plainText) ?? ''
}
const nip04Decrypt = async (pubkey: string, cipherText: string) => {
return signer?.nip04Decrypt(pubkey, cipherText) ?? ''
}
const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
if (signer) {
return cb && cb()
}
return setOpenLoginDialog(true)
}
const updateRelayListEvent = async (relayListEvent: Event) => {
const newRelayList = await indexedDb.putReplaceableEvent(relayListEvent)
setRelayList(getRelayListFromEvent(newRelayList))
}
const updateProfileEvent = async (profileEvent: Event) => {
const newProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
setProfileEvent(newProfileEvent)
setProfile(getProfileFromEvent(newProfileEvent))
}
const updateFollowListEvent = async (followListEvent: Event) => {
const newFollowListEvent = await indexedDb.putReplaceableEvent(followListEvent)
if (newFollowListEvent.id !== followListEvent.id) return
setFollowListEvent(newFollowListEvent)
await client.updateFollowListCache(newFollowListEvent)
}
const updateMuteListEvent = async (muteListEvent: Event, privateTags: string[][]) => {
const newMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent)
if (newMuteListEvent.id !== muteListEvent.id) return
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
setMuteListEvent(muteListEvent)
}
const updateBookmarkListEvent = async (bookmarkListEvent: Event) => {
const newBookmarkListEvent = await indexedDb.putReplaceableEvent(bookmarkListEvent)
if (newBookmarkListEvent.id !== bookmarkListEvent.id) return
setBookmarkListEvent(newBookmarkListEvent)
}
const updateInterestListEvent = async (interestListEvent: Event) => {
const newInterestListEvent = await indexedDb.putReplaceableEvent(interestListEvent)
if (newInterestListEvent.id !== interestListEvent.id) return
setInterestListEvent(newInterestListEvent)
}
const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => {
const newFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
if (newFavoriteRelaysEvent.id !== favoriteRelaysEvent.id) return
setFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
const updateNotificationsSeenAt = async (skipPublish = false) => {
if (!account) return
const now = dayjs().unix()
storage.setLastReadNotificationTime(account.pubkey, now)
setTimeout(() => {
setNotificationsSeenAt(now)
}, 5_000)
// Prevent too frequent requests for signing seen notifications events
const lastPublishedSeenNotificationsAtEventAt =
lastPublishedSeenNotificationsAtEventAtMap.get(account.pubkey) ?? -1
if (
!skipPublish &&
(lastPublishedSeenNotificationsAtEventAt < 0 ||
now - lastPublishedSeenNotificationsAtEventAt > 10 * 60) // 10 minutes
) {
await publish(createSeenNotificationsAtDraftEvent())
lastPublishedSeenNotificationsAtEventAtMap.set(account.pubkey, now)
}
}
return (
<NostrContext.Provider
value={{
isInitialized,
pubkey: account?.pubkey ?? null,
profile,
profileEvent,
relayList,
followListEvent,
muteListEvent,
bookmarkListEvent,
interestListEvent,
favoriteRelaysEvent,
userEmojiListEvent,
notificationsSeenAt,
account,
accounts,
nsec,
ncryptsec,
switchAccount,
nsecLogin,
ncryptsecLogin,
nip07Login,
bunkerLogin,
nostrConnectionLogin,
npubLogin,
removeAccount,
publish,
attemptDelete,
signHttpAuth,
nip04Encrypt,
nip04Decrypt,
startLogin: () => setOpenLoginDialog(true),
checkLogin,
signEvent,
updateRelayListEvent,
updateProfileEvent,
updateFollowListEvent,
updateMuteListEvent,
updateBookmarkListEvent,
updateInterestListEvent,
updateFavoriteRelaysEvent,
updateNotificationsSeenAt
}}
>
{children}
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
</NostrContext.Provider>
)
}