index.tsx raw
1 import LoginDialog from '@/components/LoginDialog'
2 import { ApplicationDataKey, ExtendedKind } from '@/constants'
3 import {
4 createDeletionRequestDraftEvent,
5 createFollowListDraftEvent,
6 createMuteListDraftEvent,
7 createRelayListDraftEvent,
8 createSeenNotificationsAtDraftEvent
9 } from '@/lib/draft-event'
10 import {
11 getLatestEvent,
12 getReplaceableEventIdentifier,
13 isProtectedEvent,
14 minePow
15 } from '@/lib/event'
16 import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
17 import { Pubkey } from '@/domain'
18 import client from '@/services/client.service'
19 import customEmojiService from '@/services/custom-emoji.service'
20 import indexedDb from '@/services/indexed-db.service'
21 import storage from '@/services/local-storage.service'
22 import stuffStatsService from '@/services/stuff-stats.service'
23 import {
24 ISigner,
25 TAccount,
26 TAccountPointer,
27 TDraftEvent,
28 TProfile,
29 TPublishOptions,
30 TRelayList
31 } from '@/types'
32 import * as nobleUtils from '@noble/curves/abstract/utils'
33 import { bech32 } from '@scure/base'
34 import dayjs from 'dayjs'
35 import { Event, kinds, VerifiedEvent } from 'nostr-tools'
36 import * as nip49 from 'nostr-tools/nip49'
37 import { createContext, useContext, useEffect, useState } from 'react'
38 import { useTranslation } from 'react-i18next'
39 import { toast } from 'sonner'
40 import { useDeletedEvent } from '../DeletedEventProvider'
41 import { usePasswordPrompt } from '../PasswordPromptProvider'
42 import { BunkerSigner, parseBunkerUrl } from './bunker.signer'
43 import { Nip07Signer } from './nip-07.signer'
44 import { NpubSigner } from './npub.signer'
45 import { NsecSigner } from './nsec.signer'
46
47 type TNostrContext = {
48 isInitialized: boolean
49 pubkey: string | null
50 profile: TProfile | null
51 profileEvent: Event | null
52 relayList: TRelayList | null
53 bookmarkListEvent: Event | null
54 favoriteRelaysEvent: Event | null
55 userEmojiListEvent: Event | null
56 pinListEvent: Event | null
57 notificationsSeenAt: number
58 account: TAccountPointer | null
59 accounts: TAccountPointer[]
60 nsec: string | null
61 ncryptsec: string | null
62 switchAccount: (account: TAccountPointer | null) => Promise<void>
63 nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise<string>
64 ncryptsecLogin: (ncryptsec: string) => Promise<string>
65 nip07Login: () => Promise<string>
66 npubLogin(npub: string): Promise<string>
67 bunkerLogin: (bunkerUrl: string) => Promise<string>
68 bunkerLoginWithSigner: (signer: BunkerSigner, pubkey: string) => Promise<string>
69 removeAccount: (account: TAccountPointer) => void
70 /**
71 * Default publish the event to current relays, user's write relays and additional relays
72 */
73 publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise<Event>
74 attemptDelete: (targetEvent: Event) => Promise<void>
75 signHttpAuth: (url: string, method: string) => Promise<string>
76 signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
77 nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
78 nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
79 nip44Encrypt: (pubkey: string, plainText: string) => Promise<string>
80 nip44Decrypt: (pubkey: string, cipherText: string) => Promise<string>
81 hasNip44Support: boolean
82 startLogin: () => void
83 checkLogin: <T>(cb?: () => T) => Promise<T | void>
84 updateRelayListEvent: (relayListEvent: Event) => Promise<void>
85 updateProfileEvent: (profileEvent: Event) => Promise<void>
86 updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
87 updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
88 updateUserEmojiListEvent: (userEmojiListEvent: Event) => Promise<void>
89 updatePinListEvent: (pinListEvent: Event) => Promise<void>
90 updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
91 }
92
93 const NostrContext = createContext<TNostrContext | undefined>(undefined)
94
95 const lastPublishedSeenNotificationsAtEventAtMap = new Map<string, number>()
96
97 export const useNostr = () => {
98 const context = useContext(NostrContext)
99 if (!context) {
100 throw new Error('useNostr must be used within a NostrProvider')
101 }
102 return context
103 }
104
105 export function NostrProvider({ children }: { children: React.ReactNode }) {
106 const { t } = useTranslation()
107 const { addDeletedEvent } = useDeletedEvent()
108 const { promptPassword } = usePasswordPrompt()
109 const [accounts, setAccounts] = useState<TAccountPointer[]>(
110 storage.getAccounts().map((act) => ({ pubkey: act.pubkey, signerType: act.signerType }))
111 )
112 const [account, setAccount] = useState<TAccountPointer | null>(null)
113 const [nsec, setNsec] = useState<string | null>(null)
114 const [ncryptsec, setNcryptsec] = useState<string | null>(null)
115 const [signer, setSigner] = useState<ISigner | null>(null)
116 const [openLoginDialog, setOpenLoginDialog] = useState(false)
117 const [profile, setProfile] = useState<TProfile | null>(null)
118 const [profileEvent, setProfileEvent] = useState<Event | null>(null)
119 const [relayList, setRelayList] = useState<TRelayList | null>(null)
120 const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
121 const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
122 const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
123 const [pinListEvent, setPinListEvent] = useState<Event | null>(null)
124 const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
125 const [isInitialized, setIsInitialized] = useState(false)
126
127 useEffect(() => {
128 const init = async () => {
129 if (hasNostrLoginHash()) {
130 await loginByNostrLoginHash()
131 setIsInitialized(true)
132 return
133 }
134
135 const accounts = storage.getAccounts()
136 const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
137 if (!act) {
138 setIsInitialized(true)
139 return
140 }
141
142 // Set account immediately so feed can load based on pubkey
143 // while signer initializes in the background
144 setAccount({ pubkey: act.pubkey, signerType: act.signerType })
145 setIsInitialized(true)
146
147 // Initialize signer in background - feed doesn't need it to load
148 await loginWithAccountPointer(act)
149 }
150 init()
151
152 const handleHashChange = () => {
153 if (hasNostrLoginHash()) {
154 loginByNostrLoginHash()
155 }
156 }
157
158 window.addEventListener('hashchange', handleHashChange)
159
160 return () => {
161 window.removeEventListener('hashchange', handleHashChange)
162 }
163 }, [])
164
165 useEffect(() => {
166 const init = async () => {
167 setRelayList(null)
168 setProfile(null)
169 setProfileEvent(null)
170 setNsec(null)
171 setFavoriteRelaysEvent(null)
172 setBookmarkListEvent(null)
173 setPinListEvent(null)
174 setNotificationsSeenAt(-1)
175 if (!account) {
176 return
177 }
178
179 const controller = new AbortController()
180 const storedNsec = storage.getAccountNsec(account.pubkey)
181 if (storedNsec) {
182 setNsec(storedNsec)
183 } else {
184 setNsec(null)
185 }
186 const storedNcryptsec = storage.getAccountNcryptsec(account.pubkey)
187 if (storedNcryptsec) {
188 setNcryptsec(storedNcryptsec)
189 } else {
190 setNcryptsec(null)
191 }
192
193 const storedNotificationsSeenAt = storage.getLastReadNotificationTime(account.pubkey)
194
195 const [
196 storedRelayListEvent,
197 storedProfileEvent,
198 storedBookmarkListEvent,
199 storedFavoriteRelaysEvent,
200 storedUserEmojiListEvent,
201 storedPinListEvent
202 ] = await Promise.all([
203 indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
204 indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
205 indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList),
206 indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS),
207 indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList),
208 indexedDb.getReplaceableEvent(account.pubkey, kinds.Pinlist)
209 ])
210 if (storedRelayListEvent) {
211 setRelayList(getRelayListFromEvent(storedRelayListEvent, storage.getFilterOutOnionRelays()))
212 }
213 if (storedProfileEvent) {
214 setProfileEvent(storedProfileEvent)
215 setProfile(getProfileFromEvent(storedProfileEvent))
216 }
217 if (storedBookmarkListEvent) {
218 setBookmarkListEvent(storedBookmarkListEvent)
219 }
220 if (storedFavoriteRelaysEvent) {
221 setFavoriteRelaysEvent(storedFavoriteRelaysEvent)
222 }
223 if (storedUserEmojiListEvent) {
224 setUserEmojiListEvent(storedUserEmojiListEvent)
225 }
226 if (storedPinListEvent) {
227 setPinListEvent(storedPinListEvent)
228 }
229
230 const relayListEvents = await client.fetchEvents(client.currentRelays, {
231 kinds: [kinds.RelayList],
232 authors: [account.pubkey]
233 })
234 const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
235 const relayList = getRelayListFromEvent(relayListEvent, storage.getFilterOutOnionRelays())
236 if (relayListEvent) {
237 client.updateRelayListCache(relayListEvent)
238 await indexedDb.putReplaceableEvent(relayListEvent)
239 }
240 setRelayList(relayList)
241
242 const events = await client.fetchEvents(relayList.write.concat(client.currentRelays).slice(0, 4), [
243 {
244 kinds: [
245 kinds.Metadata,
246 kinds.BookmarkList,
247 ExtendedKind.FAVORITE_RELAYS,
248 ExtendedKind.BLOSSOM_SERVER_LIST,
249 kinds.UserEmojiList,
250 kinds.Pinlist
251 ],
252 authors: [account.pubkey]
253 },
254 {
255 kinds: [kinds.Application],
256 authors: [account.pubkey],
257 '#d': [ApplicationDataKey.NOTIFICATIONS_SEEN_AT]
258 }
259 ])
260 const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
261 const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata)
262 const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList)
263 const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS)
264 const blossomServerListEvent = sortedEvents.find(
265 (e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
266 )
267 const userEmojiListEvent = sortedEvents.find((e) => e.kind === kinds.UserEmojiList)
268 const notificationsSeenAtEvent = sortedEvents.find(
269 (e) =>
270 e.kind === kinds.Application &&
271 getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT
272 )
273 const pinnedNotesEvent = sortedEvents.find((e) => e.kind === kinds.Pinlist)
274
275 if (profileEvent) {
276 const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
277 if (updatedProfileEvent.id === profileEvent.id) {
278 setProfileEvent(updatedProfileEvent)
279 setProfile(getProfileFromEvent(updatedProfileEvent))
280 }
281 } else if (!storedProfileEvent) {
282 const pk = Pubkey.tryFromString(account.pubkey)
283 setProfile({
284 pubkey: account.pubkey,
285 npub: pk?.npub ?? '',
286 username: pk?.formatNpub(12) ?? account.pubkey.slice(0, 8)
287 })
288 }
289 if (bookmarkListEvent) {
290 const updateBookmarkListEvent = await indexedDb.putReplaceableEvent(bookmarkListEvent)
291 if (updateBookmarkListEvent.id === bookmarkListEvent.id) {
292 setBookmarkListEvent(bookmarkListEvent)
293 }
294 }
295 if (favoriteRelaysEvent) {
296 const updatedFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
297 if (updatedFavoriteRelaysEvent.id === favoriteRelaysEvent.id) {
298 setFavoriteRelaysEvent(updatedFavoriteRelaysEvent)
299 }
300 }
301 if (blossomServerListEvent) {
302 await client.updateBlossomServerListEventCache(blossomServerListEvent)
303 }
304 if (userEmojiListEvent) {
305 const updatedUserEmojiListEvent = await indexedDb.putReplaceableEvent(userEmojiListEvent)
306 if (updatedUserEmojiListEvent.id === userEmojiListEvent.id) {
307 setUserEmojiListEvent(updatedUserEmojiListEvent)
308 }
309 }
310 if (pinnedNotesEvent) {
311 const updatedPinnedNotesEvent = await indexedDb.putReplaceableEvent(pinnedNotesEvent)
312 if (updatedPinnedNotesEvent.id === pinnedNotesEvent.id) {
313 setPinListEvent(updatedPinnedNotesEvent)
314 }
315 }
316
317 const notificationsSeenAt = Math.max(
318 notificationsSeenAtEvent?.created_at ?? 0,
319 storedNotificationsSeenAt
320 )
321 setNotificationsSeenAt(notificationsSeenAt)
322 storage.setLastReadNotificationTime(account.pubkey, notificationsSeenAt)
323
324 client.initUserIndexFromFollowings(account.pubkey, controller.signal)
325 return controller
326 }
327 const promise = init()
328 return () => {
329 promise.then((controller) => {
330 controller?.abort()
331 })
332 }
333 }, [account])
334
335 useEffect(() => {
336 if (!account) return
337
338 const initInteractions = async () => {
339 const pubkey = account.pubkey
340 const relayList = await client.fetchRelayList(pubkey)
341 const events = await client.fetchEvents(relayList.write.slice(0, 4), [
342 {
343 authors: [pubkey],
344 kinds: [kinds.Reaction, kinds.Repost],
345 limit: 100
346 },
347 {
348 '#P': [pubkey],
349 kinds: [kinds.Zap],
350 limit: 100
351 }
352 ])
353 stuffStatsService.updateStuffStatsByEvents(events)
354 }
355 initInteractions()
356 }, [account])
357
358 useEffect(() => {
359 if (signer) {
360 client.signer = signer
361 } else {
362 client.signer = undefined
363 }
364 }, [signer])
365
366 useEffect(() => {
367 if (account) {
368 client.pubkey = account.pubkey
369 } else {
370 client.pubkey = undefined
371 }
372 }, [account])
373
374 useEffect(() => {
375 customEmojiService.init(userEmojiListEvent)
376 }, [userEmojiListEvent])
377
378 const hasNostrLoginHash = () => {
379 return window.location.hash && window.location.hash.startsWith('#nostr-login')
380 }
381
382 const loginByNostrLoginHash = async () => {
383 const credential = window.location.hash.replace('#nostr-login=', '')
384 const urlWithoutHash = window.location.href.split('#')[0]
385 history.replaceState(null, '', urlWithoutHash)
386
387 if (credential.startsWith('ncryptsec')) {
388 return await ncryptsecLogin(credential)
389 } else if (credential.startsWith('nsec')) {
390 return await nsecLogin(credential)
391 }
392 }
393
394 const login = (signer: ISigner, act: TAccount) => {
395 const newAccounts = storage.addAccount(act)
396 setAccounts(newAccounts)
397 storage.switchAccount(act)
398 setAccount({ pubkey: act.pubkey, signerType: act.signerType })
399 setSigner(signer)
400 return act.pubkey
401 }
402
403 const removeAccount = (act: TAccountPointer) => {
404 const newAccounts = storage.removeAccount(act)
405 setAccounts(newAccounts)
406 if (account?.pubkey === act.pubkey) {
407 setAccount(null)
408 setSigner(null)
409 }
410 }
411
412 const switchAccount = async (act: TAccountPointer | null) => {
413 if (!act) {
414 storage.switchAccount(null)
415 setAccount(null)
416 setSigner(null)
417 return
418 }
419 await loginWithAccountPointer(act)
420 }
421
422 const nsecLogin = async (nsecOrHex: string, password?: string, needSetup?: boolean) => {
423 const nsecSigner = new NsecSigner()
424 let privkey: Uint8Array
425 const input = nsecOrHex.trim()
426
427 if (input.startsWith('nsec')) {
428 // Use @scure/base bech32 for robust decoding (same as plebeian-signer)
429 try {
430 const { prefix, words } = bech32.decode(input as `${string}1${string}`, 5000)
431 if (prefix !== 'nsec') {
432 throw new Error('invalid nsec prefix')
433 }
434 privkey = new Uint8Array(bech32.fromWords(words))
435 } catch (err) {
436 throw new Error(`invalid nsec: ${err instanceof Error ? err.message : 'decode failed'}`)
437 }
438 } else if (/^[0-9a-fA-F]{64}$/.test(input)) {
439 privkey = nobleUtils.hexToBytes(input)
440 } else {
441 throw new Error('invalid nsec or hex')
442 }
443
444 const pubkey = nsecSigner.login(privkey)
445 if (password) {
446 const ncryptsec = nip49.encrypt(privkey, password)
447 login(nsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec })
448 } else {
449 // Use bech32 encode for consistency
450 const words = bech32.toWords(privkey)
451 const nsec = bech32.encode('nsec', words, 5000)
452 login(nsecSigner, { pubkey, signerType: 'nsec', nsec })
453 }
454 if (needSetup) {
455 setupNewUser(nsecSigner)
456 }
457 return pubkey
458 }
459
460 const ncryptsecLogin = async (ncryptsec: string) => {
461 const password = await promptPassword(t('Enter the password to decrypt your ncryptsec'))
462 if (!password) {
463 throw new Error('Password is required')
464 }
465 const privkey = nip49.decrypt(ncryptsec, password)
466 const browserNsecSigner = new NsecSigner()
467 const pubkey = browserNsecSigner.login(privkey)
468 return login(browserNsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec })
469 }
470
471 const npubLogin = async (npub: string) => {
472 const npubSigner = new NpubSigner()
473 const pubkey = npubSigner.login(npub)
474 return login(npubSigner, { pubkey, signerType: 'npub', npub })
475 }
476
477 const nip07Login = async () => {
478 try {
479 const nip07Signer = new Nip07Signer()
480 await nip07Signer.init()
481 const pubkey = await nip07Signer.getPublicKey()
482 if (!pubkey) {
483 throw new Error('You did not allow to access your pubkey')
484 }
485 return login(nip07Signer, { pubkey, signerType: 'nip-07' })
486 } catch (err) {
487 toast.error(t('Login failed') + ': ' + (err as Error).message)
488 throw err
489 }
490 }
491
492 const bunkerLogin = async (bunkerUrl: string) => {
493 try {
494 const { pubkey: bunkerPubkey, relays, secret } = parseBunkerUrl(bunkerUrl)
495 const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret)
496 await bunkerSigner.init()
497 const pubkey = await bunkerSigner.getPublicKey()
498 return login(bunkerSigner, {
499 pubkey,
500 signerType: 'bunker',
501 bunkerPubkey,
502 bunkerRelays: relays,
503 bunkerSecret: secret
504 })
505 } catch (err) {
506 toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
507 throw err
508 }
509 }
510
511 /**
512 * Login with an already-connected BunkerSigner instance.
513 * Used for the nostr+connect flow where we wait for signer to connect.
514 */
515 const bunkerLoginWithSigner = async (signer: BunkerSigner, pubkey: string) => {
516 try {
517 return login(signer, {
518 pubkey,
519 signerType: 'bunker',
520 bunkerPubkey: signer.getBunkerPubkey(),
521 bunkerRelays: signer.getRelayUrls(),
522 bunkerSecret: undefined
523 })
524 } catch (err) {
525 toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
526 throw err
527 }
528 }
529
530 const loginWithAccountPointer = async (act: TAccountPointer): Promise<string | null> => {
531 let account = storage.findAccount(act)
532 if (!account) {
533 return null
534 }
535 if (account.signerType === 'nsec' || account.signerType === 'browser-nsec') {
536 if (account.nsec) {
537 const browserNsecSigner = new NsecSigner()
538 browserNsecSigner.login(account.nsec)
539 // Migrate to nsec
540 if (account.signerType === 'browser-nsec') {
541 storage.removeAccount(account)
542 account = { ...account, signerType: 'nsec' }
543 storage.addAccount(account)
544 }
545 return login(browserNsecSigner, account)
546 }
547 } else if (account.signerType === 'ncryptsec') {
548 if (account.ncryptsec) {
549 const password = await promptPassword(t('Enter the password to decrypt your ncryptsec'))
550 if (!password) {
551 return null
552 }
553 const privkey = nip49.decrypt(account.ncryptsec, password)
554 const browserNsecSigner = new NsecSigner()
555 browserNsecSigner.login(privkey)
556 return login(browserNsecSigner, account)
557 }
558 } else if (account.signerType === 'nip-07') {
559 const nip07Signer = new Nip07Signer()
560 await nip07Signer.init()
561 return login(nip07Signer, account)
562 } else if (account.signerType === 'npub' && account.npub) {
563 const npubSigner = new NpubSigner()
564 const pubkey = npubSigner.login(account.npub)
565 if (!pubkey) {
566 storage.removeAccount(account)
567 return null
568 }
569 if (pubkey !== account.pubkey) {
570 storage.removeAccount(account)
571 account = { ...account, pubkey }
572 storage.addAccount(account)
573 }
574 return login(npubSigner, account)
575 } else if (account.signerType === 'bunker' && account.bunkerPubkey && account.bunkerRelays) {
576 try {
577 const bunkerSigner = new BunkerSigner(
578 account.bunkerPubkey,
579 account.bunkerRelays,
580 account.bunkerSecret
581 )
582 await bunkerSigner.init()
583 return login(bunkerSigner, account)
584 } catch (err) {
585 console.error('Failed to reconnect to bunker:', err)
586 toast.error(t('Failed to reconnect to bunker'))
587 return null
588 }
589 }
590 storage.removeAccount(account)
591 return null
592 }
593
594 const setupNewUser = async (signer: ISigner) => {
595 // Use currently connected relays as the bootstrap relays for new users
596 const bootstrapRelays = client.currentRelays.length > 0 ? client.currentRelays : []
597 if (bootstrapRelays.length === 0) return
598
599 await Promise.allSettled([
600 client.publishEvent(bootstrapRelays, await signer.signEvent(createFollowListDraftEvent([]))),
601 client.publishEvent(bootstrapRelays, await signer.signEvent(createMuteListDraftEvent([]))),
602 client.publishEvent(
603 bootstrapRelays,
604 await signer.signEvent(
605 createRelayListDraftEvent(bootstrapRelays.map((url) => ({ url, scope: 'both' })))
606 )
607 )
608 ])
609 }
610
611 const signEvent = async (draftEvent: TDraftEvent) => {
612 const event = await signer?.signEvent(draftEvent)
613 if (!event) {
614 throw new Error('sign event failed')
615 }
616 return event as VerifiedEvent
617 }
618
619 const publish = async (
620 draftEvent: TDraftEvent,
621 { minPow = 0, ...options }: TPublishOptions = {}
622 ) => {
623 if (!account || !signer || account.signerType === 'npub') {
624 throw new Error('You need to login first')
625 }
626
627 const draft = JSON.parse(JSON.stringify(draftEvent)) as TDraftEvent
628 let event: VerifiedEvent
629 if (minPow > 0) {
630 const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow)
631 event = await signEvent(unsignedEvent)
632 } else {
633 event = await signEvent(draft)
634 }
635
636 if (event.kind !== kinds.Application && event.pubkey !== account.pubkey) {
637 const eventAuthor = await client.fetchProfile(event.pubkey)
638 const result = confirm(
639 t(
640 'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?',
641 { eventAuthorName: eventAuthor?.username, currentUsername: profile?.username }
642 )
643 )
644 if (!result) {
645 throw new Error(t('Cancelled'))
646 }
647 }
648
649 const relays = await client.determineTargetRelays(event, options)
650
651 await client.publishEvent(relays, event)
652 return event
653 }
654
655 const attemptDelete = async (targetEvent: Event) => {
656 if (!signer) {
657 throw new Error(t('You need to login first'))
658 }
659 if (account?.pubkey !== targetEvent.pubkey) {
660 throw new Error(t('You can only delete your own notes'))
661 }
662
663 const deletionRequest = await signEvent(createDeletionRequestDraftEvent(targetEvent))
664
665 const seenOn = client.getSeenEventRelayUrls(targetEvent.id)
666 const relays = await client.determineTargetRelays(targetEvent, {
667 specifiedRelayUrls: isProtectedEvent(targetEvent) ? seenOn : undefined,
668 additionalRelayUrls: seenOn
669 })
670
671 await client.publishEvent(relays, deletionRequest)
672
673 addDeletedEvent(targetEvent)
674 toast.success(t('Deletion request sent to {{count}} relays', { count: relays.length }))
675 }
676
677 const signHttpAuth = async (url: string, method: string, content = '') => {
678 const event = await signEvent({
679 content,
680 kind: kinds.HTTPAuth,
681 created_at: dayjs().unix(),
682 tags: [
683 ['u', url],
684 ['method', method]
685 ]
686 })
687 return 'Nostr ' + btoa(JSON.stringify(event))
688 }
689
690 const nip04Encrypt = async (pubkey: string, plainText: string) => {
691 if (!signer) {
692 throw new Error('No signer available for NIP-04 encryption')
693 }
694 try {
695 const result = await signer.nip04Encrypt(pubkey, plainText)
696 if (!result) {
697 throw new Error('NIP-04 encryption returned empty result')
698 }
699 return result
700 } catch (err) {
701 console.error('NIP-04 encryption failed:', err)
702 throw err
703 }
704 }
705
706 const nip04Decrypt = async (pubkey: string, cipherText: string) => {
707 return signer?.nip04Decrypt(pubkey, cipherText) ?? ''
708 }
709
710 const nip44Encrypt = async (pubkey: string, plainText: string) => {
711 if (!signer?.nip44Encrypt) {
712 throw new Error('NIP-44 encryption not supported by this signer')
713 }
714 return signer.nip44Encrypt(pubkey, plainText)
715 }
716
717 const nip44Decrypt = async (pubkey: string, cipherText: string) => {
718 if (!signer?.nip44Decrypt) {
719 throw new Error('NIP-44 decryption not supported by this signer')
720 }
721 return signer.nip44Decrypt(pubkey, cipherText)
722 }
723
724 const hasNip44Support = !!signer?.nip44Encrypt && !!signer?.nip44Decrypt
725
726 const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
727 if (signer) {
728 return cb && cb()
729 }
730 return setOpenLoginDialog(true)
731 }
732
733 const updateRelayListEvent = async (relayListEvent: Event) => {
734 const newRelayList = await client.updateRelayListCache(relayListEvent)
735 setRelayList(getRelayListFromEvent(newRelayList, storage.getFilterOutOnionRelays()))
736 }
737
738 const updateProfileEvent = async (profileEvent: Event) => {
739 const newProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
740 setProfileEvent(newProfileEvent)
741 setProfile(getProfileFromEvent(newProfileEvent))
742 }
743
744 const updateBookmarkListEvent = async (bookmarkListEvent: Event) => {
745 const newBookmarkListEvent = await indexedDb.putReplaceableEvent(bookmarkListEvent)
746 if (newBookmarkListEvent.id !== bookmarkListEvent.id) return
747
748 setBookmarkListEvent(newBookmarkListEvent)
749 }
750
751 const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => {
752 const newFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
753 if (newFavoriteRelaysEvent.id !== favoriteRelaysEvent.id) return
754
755 setFavoriteRelaysEvent(newFavoriteRelaysEvent)
756 }
757
758 const updateUserEmojiListEvent = async (userEmojiListEvent: Event) => {
759 const newUserEmojiListEvent = await indexedDb.putReplaceableEvent(userEmojiListEvent)
760 if (newUserEmojiListEvent.id !== userEmojiListEvent.id) return
761
762 setUserEmojiListEvent(newUserEmojiListEvent)
763 }
764
765 const updatePinListEvent = async (pinListEvent: Event) => {
766 const newPinListEvent = await indexedDb.putReplaceableEvent(pinListEvent)
767 if (newPinListEvent.id !== pinListEvent.id) return
768
769 setPinListEvent(newPinListEvent)
770 }
771
772 const updateNotificationsSeenAt = async (skipPublish = false) => {
773 if (!account) return
774
775 const now = dayjs().unix()
776 storage.setLastReadNotificationTime(account.pubkey, now)
777 setTimeout(() => {
778 setNotificationsSeenAt(now)
779 }, 5_000)
780
781 // Prevent too frequent requests for signing seen notifications events
782 const lastPublishedSeenNotificationsAtEventAt =
783 lastPublishedSeenNotificationsAtEventAtMap.get(account.pubkey) ?? -1
784 if (
785 !skipPublish &&
786 (lastPublishedSeenNotificationsAtEventAt < 0 ||
787 now - lastPublishedSeenNotificationsAtEventAt > 10 * 60) // 10 minutes
788 ) {
789 await publish(createSeenNotificationsAtDraftEvent())
790 lastPublishedSeenNotificationsAtEventAtMap.set(account.pubkey, now)
791 }
792 }
793
794 return (
795 <NostrContext.Provider
796 value={{
797 isInitialized,
798 pubkey: account?.pubkey ?? null,
799 profile,
800 profileEvent,
801 relayList,
802 bookmarkListEvent,
803 favoriteRelaysEvent,
804 userEmojiListEvent,
805 pinListEvent,
806 notificationsSeenAt,
807 account,
808 accounts,
809 nsec,
810 ncryptsec,
811 switchAccount,
812 nsecLogin,
813 ncryptsecLogin,
814 nip07Login,
815 npubLogin,
816 bunkerLogin,
817 bunkerLoginWithSigner,
818 removeAccount,
819 publish,
820 attemptDelete,
821 signHttpAuth,
822 nip04Encrypt,
823 nip04Decrypt,
824 nip44Encrypt,
825 nip44Decrypt,
826 hasNip44Support,
827 startLogin: () => setOpenLoginDialog(true),
828 checkLogin,
829 signEvent,
830 updateRelayListEvent,
831 updateProfileEvent,
832 updateBookmarkListEvent,
833 updateFavoriteRelaysEvent,
834 updateUserEmojiListEvent,
835 updatePinListEvent,
836 updateNotificationsSeenAt
837 }}
838 >
839 {children}
840 <LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
841 </NostrContext.Provider>
842 )
843 }
844