feat: zap (#107)
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
SimplePool,
|
||||
VerifiedEvent
|
||||
} from 'nostr-tools'
|
||||
import { SubscribeManyParams } from 'nostr-tools/abstract-pool'
|
||||
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||
import indexedDb from './indexed-db.service'
|
||||
|
||||
@@ -44,28 +45,23 @@ class ClientService extends EventTarget {
|
||||
{ cacheMap: this.eventCache }
|
||||
)
|
||||
private fetchEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
this.eventBatchLoadFn.bind(this),
|
||||
{ cache: false }
|
||||
)
|
||||
private profileEventDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
(ids) => Promise.all(ids.map((id) => this._fetchProfileEvent(id))),
|
||||
{
|
||||
cache: false,
|
||||
maxBatchSize: 50
|
||||
}
|
||||
this.fetchEventsFromBigRelays.bind(this),
|
||||
{ cache: false, batchScheduleFn: (callback) => setTimeout(callback, 200) }
|
||||
)
|
||||
private fetchProfileEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
this.profileEventBatchLoadFn.bind(this),
|
||||
{
|
||||
batchScheduleFn: (callback) => setTimeout(callback, 200),
|
||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
||||
maxBatchSize: 50
|
||||
maxBatchSize: 20
|
||||
}
|
||||
)
|
||||
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
||||
this.relayListEventBatchLoadFn.bind(this),
|
||||
{
|
||||
batchScheduleFn: (callback) => setTimeout(callback, 200),
|
||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
||||
maxBatchSize: 50
|
||||
maxBatchSize: 20
|
||||
}
|
||||
)
|
||||
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||
@@ -166,7 +162,8 @@ class ClientService extends EventTarget {
|
||||
needSort?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const key = this.generateTimelineKey(urls, filter)
|
||||
const relays = Array.from(new Set(urls))
|
||||
const key = this.generateTimelineKey(relays, filter)
|
||||
const timeline = this.timelines[key]
|
||||
let cachedEvents: NEvent[] = []
|
||||
let since: number | undefined
|
||||
@@ -183,7 +180,7 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
|
||||
if (!timeline && needSort) {
|
||||
this.timelines[key] = { refs: [], filter, urls }
|
||||
this.timelines[key] = { refs: [], filter, urls: relays }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
@@ -193,7 +190,7 @@ class ClientService extends EventTarget {
|
||||
let startedCount = 0
|
||||
let eosedCount = 0
|
||||
let eosed = false
|
||||
const subPromises = urls.map(async (url) => {
|
||||
const subPromises = relays.map(async (url) => {
|
||||
const relay = await this.pool.ensureRelay(url)
|
||||
let hasAuthed = false
|
||||
|
||||
@@ -345,11 +342,19 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
async query(urls: string[], filter: Filter) {
|
||||
subscribe(urls: string[], filter: Filter | Filter[], params: SubscribeManyParams) {
|
||||
const relays = Array.from(new Set(urls))
|
||||
const filters = Array.isArray(filter) ? filter : [filter]
|
||||
return this.pool.subscribeMany(relays, filters, params)
|
||||
}
|
||||
|
||||
private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) {
|
||||
const relays = Array.from(new Set(urls))
|
||||
const filters = Array.isArray(filter) ? filter : [filter]
|
||||
const _knownIds = new Set<string>()
|
||||
const events: NEvent[] = []
|
||||
await Promise.allSettled(
|
||||
urls.map(async (url) => {
|
||||
relays.map(async (url) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const that = this
|
||||
const relay = await this.pool.ensureRelay(url)
|
||||
@@ -357,7 +362,7 @@ class ClientService extends EventTarget {
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const startQuery = () => {
|
||||
relay.subscribe([filter], {
|
||||
relay.subscribe(filters, {
|
||||
receivedEvent(relay, id) {
|
||||
that.trackEventSeenOn(id, relay)
|
||||
},
|
||||
@@ -384,6 +389,7 @@ class ClientService extends EventTarget {
|
||||
if (_knownIds.has(evt.id)) return
|
||||
_knownIds.add(evt.id)
|
||||
events.push(evt)
|
||||
onevent?.(evt)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -421,10 +427,22 @@ class ClientService extends EventTarget {
|
||||
return events
|
||||
}
|
||||
|
||||
async fetchEvents(relayUrls: string[], filter: Filter, cache = false) {
|
||||
async fetchEvents(
|
||||
urls: string[],
|
||||
filter: Filter | Filter[],
|
||||
{
|
||||
onevent,
|
||||
cache = false
|
||||
}: {
|
||||
onevent?: (evt: NEvent) => void
|
||||
cache?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const relays = Array.from(new Set(urls))
|
||||
const events = await this.query(
|
||||
relayUrls.length > 0 ? relayUrls : this.currentRelayUrls.concat(BIG_RELAY_URLS),
|
||||
filter
|
||||
relays.length > 0 ? relays : this.currentRelayUrls.concat(BIG_RELAY_URLS),
|
||||
filter,
|
||||
onevent
|
||||
)
|
||||
if (cache) {
|
||||
events.forEach((evt) => {
|
||||
@@ -460,12 +478,70 @@ class ClientService extends EventTarget {
|
||||
this.eventDataLoader.prime(event.id, Promise.resolve(event))
|
||||
}
|
||||
|
||||
async fetchProfileEvent(id: string): Promise<NEvent | undefined> {
|
||||
return await this.profileEventDataloader.load(id)
|
||||
async fetchProfileEvent(id: string, skipCache: boolean = false): Promise<NEvent | undefined> {
|
||||
let pubkey: string | undefined
|
||||
let relays: string[] = []
|
||||
if (/^[0-9a-f]{64}$/.test(id)) {
|
||||
pubkey = id
|
||||
} else {
|
||||
const { data, type } = nip19.decode(id)
|
||||
switch (type) {
|
||||
case 'npub':
|
||||
pubkey = data
|
||||
break
|
||||
case 'nprofile':
|
||||
pubkey = data.pubkey
|
||||
if (data.relays) relays = data.relays
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
throw new Error('Invalid id')
|
||||
}
|
||||
if (!skipCache) {
|
||||
const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
|
||||
if (localProfile) {
|
||||
this.addUsernameToIndex(localProfile)
|
||||
return localProfile
|
||||
}
|
||||
}
|
||||
const profileFromBigRelays = await this.fetchProfileEventFromBigRelaysDataloader.load(pubkey)
|
||||
if (profileFromBigRelays) {
|
||||
this.addUsernameToIndex(profileFromBigRelays)
|
||||
await indexedDb.putReplaceableEvent(profileFromBigRelays)
|
||||
return profileFromBigRelays
|
||||
}
|
||||
|
||||
if (!relays.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const profileEvent = await this.tryHarderToFetchEvent(
|
||||
relays,
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Metadata],
|
||||
limit: 1
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
if (profileEvent) {
|
||||
this.addUsernameToIndex(profileEvent)
|
||||
indexedDb.putReplaceableEvent(profileEvent)
|
||||
}
|
||||
|
||||
return profileEvent
|
||||
}
|
||||
|
||||
async fetchProfile(id: string): Promise<TProfile | undefined> {
|
||||
const profileEvent = await this.fetchProfileEvent(id)
|
||||
async fetchProfile(id: string, skipCache: boolean = false): Promise<TProfile | undefined> {
|
||||
let profileEvent: NEvent | undefined
|
||||
if (skipCache) {
|
||||
profileEvent = await this.fetchProfileEvent(id, skipCache)
|
||||
} else {
|
||||
profileEvent = await this.fetchProfileEvent(id)
|
||||
}
|
||||
if (profileEvent) {
|
||||
return getProfileFromProfileEvent(profileEvent)
|
||||
}
|
||||
@@ -478,11 +554,6 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
updateProfileCache(event: NEvent) {
|
||||
this.profileEventDataloader.clear(event.pubkey)
|
||||
this.profileEventDataloader.prime(event.pubkey, Promise.resolve(event))
|
||||
}
|
||||
|
||||
async fetchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
|
||||
const events = await this.query(relayUrls, {
|
||||
...filter,
|
||||
@@ -490,7 +561,6 @@ class ClientService extends EventTarget {
|
||||
})
|
||||
|
||||
const profileEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
profileEvents.forEach((profile) => this.profileEventDataloader.prime(profile.pubkey, profile))
|
||||
await Promise.all(profileEvents.map((profile) => this.addUsernameToIndex(profile)))
|
||||
return profileEvents.map((profileEvent) => getProfileFromProfileEvent(profileEvent))
|
||||
}
|
||||
@@ -519,17 +589,22 @@ class ClientService extends EventTarget {
|
||||
return event
|
||||
}
|
||||
|
||||
async fetchFollowings(pubkey: string) {
|
||||
const followListEvent = await this.fetchFollowListEvent(pubkey)
|
||||
async fetchFollowings(pubkey: string, storeToIndexedDb = false) {
|
||||
const followListEvent = await this.fetchFollowListEvent(pubkey, storeToIndexedDb)
|
||||
return followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []
|
||||
}
|
||||
|
||||
updateFollowListCache(pubkey: string, event: NEvent) {
|
||||
this.followListCache.set(pubkey, Promise.resolve(event))
|
||||
updateFollowListCache(event: NEvent) {
|
||||
this.followListCache.set(event.pubkey, Promise.resolve(event))
|
||||
}
|
||||
|
||||
updateRelayListCache(event: NEvent) {
|
||||
this.relayListEventDataLoader.clear(event.pubkey)
|
||||
this.relayListEventDataLoader.prime(event.pubkey, Promise.resolve(event))
|
||||
}
|
||||
|
||||
async calculateOptimalReadRelays(pubkey: string) {
|
||||
const followings = await this.fetchFollowings(pubkey)
|
||||
const followings = await this.fetchFollowings(pubkey, true)
|
||||
const [selfRelayListEvent, ...relayListEvents] = await this.relayListEventDataLoader.loadMany([
|
||||
pubkey,
|
||||
...followings
|
||||
@@ -544,7 +619,6 @@ class ClientService extends EventTarget {
|
||||
pubkeyRelayListMap.set(evt.pubkey, getRelayListFromRelayListEvent(evt).write)
|
||||
}
|
||||
})
|
||||
|
||||
let uncoveredPubkeys = [...followings]
|
||||
const readRelays: { url: string; pubkeys: string[] }[] = []
|
||||
while (uncoveredPubkeys.length) {
|
||||
@@ -571,7 +645,6 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
}
|
||||
if (!maxCoveredRelay) break
|
||||
|
||||
readRelays.push(maxCoveredRelay)
|
||||
uncoveredPubkeys = uncoveredPubkeys.filter(
|
||||
(pubkey) => !maxCoveredRelay!.pubkeys.includes(pubkey)
|
||||
@@ -588,12 +661,13 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
|
||||
async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
|
||||
const followings = await this.fetchFollowings(pubkey)
|
||||
for (let i = 0; i * 50 < followings.length; i++) {
|
||||
const followings = await this.fetchFollowings(pubkey, true)
|
||||
for (let i = 0; i * 20 < followings.length; i++) {
|
||||
if (signal.aborted) return
|
||||
|
||||
await this.profileEventDataloader.loadMany(followings.slice(i * 50, (i + 1) * 50))
|
||||
await new Promise((resolve) => setTimeout(resolve, 30000))
|
||||
await Promise.all(
|
||||
followings.slice(i * 20, (i + 1) * 20).map((pubkey) => this.fetchProfileEvent(pubkey))
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,9 +739,7 @@ class ClientService extends EventTarget {
|
||||
let event: NEvent | undefined
|
||||
if (filter.ids) {
|
||||
event = await this.fetchEventById(relays, filter.ids[0])
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
} else {
|
||||
event = await this.tryHarderToFetchEvent(relays, filter)
|
||||
}
|
||||
|
||||
@@ -678,62 +750,6 @@ class ClientService extends EventTarget {
|
||||
return event
|
||||
}
|
||||
|
||||
private async _fetchProfileEvent(id: string): Promise<NEvent | undefined> {
|
||||
let pubkey: string | undefined
|
||||
let relays: string[] = []
|
||||
if (/^[0-9a-f]{64}$/.test(id)) {
|
||||
pubkey = id
|
||||
} else {
|
||||
const { data, type } = nip19.decode(id)
|
||||
switch (type) {
|
||||
case 'npub':
|
||||
pubkey = data
|
||||
break
|
||||
case 'nprofile':
|
||||
pubkey = data.pubkey
|
||||
if (data.relays) relays = data.relays
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
throw new Error('Invalid id')
|
||||
}
|
||||
const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
|
||||
if (localProfile) {
|
||||
this.addUsernameToIndex(localProfile)
|
||||
return localProfile
|
||||
}
|
||||
const profileFromBigRelays = await this.fetchProfileEventFromBigRelaysDataloader.load(pubkey)
|
||||
if (profileFromBigRelays) {
|
||||
this.addUsernameToIndex(profileFromBigRelays)
|
||||
await indexedDb.putReplaceableEvent(profileFromBigRelays)
|
||||
return profileFromBigRelays
|
||||
}
|
||||
|
||||
const profileEvent = await this.tryHarderToFetchEvent(
|
||||
relays,
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Metadata],
|
||||
limit: 1
|
||||
},
|
||||
true
|
||||
)
|
||||
if (pubkey !== id) {
|
||||
this.profileEventDataloader.prime(pubkey, Promise.resolve(profileEvent))
|
||||
}
|
||||
|
||||
if (profileEvent) {
|
||||
await Promise.allSettled([
|
||||
this.addUsernameToIndex(profileEvent),
|
||||
indexedDb.putReplaceableEvent(profileEvent)
|
||||
])
|
||||
}
|
||||
|
||||
return profileEvent
|
||||
}
|
||||
|
||||
private async addUsernameToIndex(profileEvent: NEvent) {
|
||||
try {
|
||||
const profileObj = JSON.parse(profileEvent.content)
|
||||
@@ -772,7 +788,7 @@ class ClientService extends EventTarget {
|
||||
return events.sort((a, b) => b.created_at - a.created_at)[0]
|
||||
}
|
||||
|
||||
private async eventBatchLoadFn(ids: readonly string[]) {
|
||||
private async fetchEventsFromBigRelays(ids: readonly string[]) {
|
||||
const events = await this.query(BIG_RELAY_URLS, {
|
||||
ids: Array.from(new Set(ids)),
|
||||
limit: ids.length
|
||||
@@ -803,10 +819,8 @@ class ClientService extends EventTarget {
|
||||
return eventsMap.get(pubkey)
|
||||
})
|
||||
|
||||
await Promise.allSettled(
|
||||
profileEvents.map(
|
||||
(profileEvent) => profileEvent && indexedDb.putReplaceableEvent(profileEvent)
|
||||
)
|
||||
profileEvents.forEach(
|
||||
(profileEvent) => profileEvent && indexedDb.putReplaceableEvent(profileEvent)
|
||||
)
|
||||
return profileEvents
|
||||
}
|
||||
@@ -830,9 +844,7 @@ class ClientService extends EventTarget {
|
||||
eventsMap.set(pubkey, event)
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(
|
||||
Array.from(eventsMap.values()).map((evt) => indexedDb.putReplaceableEvent(evt))
|
||||
)
|
||||
Array.from(eventsMap.values()).forEach((evt) => indexedDb.putReplaceableEvent(evt))
|
||||
nonExistingPubkeys.forEach((pubkey) => {
|
||||
const event = eventsMap.get(pubkey)
|
||||
if (event) {
|
||||
@@ -846,6 +858,11 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
|
||||
private async _fetchFollowListEvent(pubkey: string) {
|
||||
const storedFollowListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts)
|
||||
if (storedFollowListEvent) {
|
||||
return storedFollowListEvent
|
||||
}
|
||||
|
||||
const relayList = await this.fetchRelayList(pubkey)
|
||||
const followListEvents = await this.query(relayList.write.concat(BIG_RELAY_URLS), {
|
||||
authors: [pubkey],
|
||||
|
||||
@@ -44,25 +44,26 @@ class IndexedDbService {
|
||||
}
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
this.db = request.result
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' })
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) {
|
||||
db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' })
|
||||
if (!db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) {
|
||||
db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' })
|
||||
if (!db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) {
|
||||
db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' })
|
||||
if (!db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) {
|
||||
db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
|
||||
this.db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' })
|
||||
if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
|
||||
db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.RELAY_INFO_EVENTS, { keyPath: 'key' })
|
||||
if (!db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
|
||||
db.createObjectStore(StoreNames.RELAY_INFO_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
this.db = db
|
||||
}
|
||||
})
|
||||
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
|
||||
@@ -98,6 +99,10 @@ class IndexedDbService {
|
||||
reject(event)
|
||||
}
|
||||
}
|
||||
|
||||
getRequest.onerror = (event) => {
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -264,12 +269,28 @@ class IndexedDbService {
|
||||
return
|
||||
}
|
||||
|
||||
const expirationTimestamp = Date.now() - 1000 * 60 * 60 * 24 // 1 day
|
||||
const transaction = this.db!.transaction(Object.values(StoreNames), 'readwrite')
|
||||
const stores = [
|
||||
{ name: StoreNames.PROFILE_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
|
||||
{ name: StoreNames.RELAY_LIST_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
|
||||
{
|
||||
name: StoreNames.FOLLOW_LIST_EVENTS,
|
||||
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
|
||||
}, // 1 day
|
||||
{ name: StoreNames.RELAY_INFO_EVENTS, expirationTimestamp: -1 },
|
||||
{ name: StoreNames.MUTE_LIST_EVENTS, expirationTimestamp: -1 },
|
||||
{ name: StoreNames.MUTE_DECRYPTED_TAGS, expirationTimestamp: -1 }
|
||||
]
|
||||
const transaction = this.db!.transaction(
|
||||
stores.map((store) => store.name),
|
||||
'readwrite'
|
||||
)
|
||||
await Promise.allSettled(
|
||||
Object.values(StoreNames).map((storeName) => {
|
||||
stores.map(({ name, expirationTimestamp }) => {
|
||||
if (expirationTimestamp < 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const store = transaction.objectStore(storeName)
|
||||
const store = transaction.objectStore(name)
|
||||
const request = store.openCursor()
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result
|
||||
|
||||
192
src/services/lightning.service.ts
Normal file
192
src/services/lightning.service.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { extractZapInfoFromReceipt } from '@/lib/event'
|
||||
import { TProfile } from '@/types'
|
||||
import {
|
||||
init,
|
||||
launchPaymentModal,
|
||||
onConnected,
|
||||
onDisconnected
|
||||
} from '@getalby/bitcoin-connect-react'
|
||||
import { Invoice } from '@getalby/lightning-tools'
|
||||
import { bech32 } from '@scure/base'
|
||||
import { WebLNProvider } from '@webbtc/webln-types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Filter, kinds } from 'nostr-tools'
|
||||
import { SubCloser } from 'nostr-tools/abstract-pool'
|
||||
import { makeZapRequest } from 'nostr-tools/nip57'
|
||||
import { utf8Decoder } from 'nostr-tools/utils'
|
||||
import client from './client.service'
|
||||
|
||||
class LightningService {
|
||||
static instance: LightningService
|
||||
private provider: WebLNProvider | null = null
|
||||
|
||||
constructor() {
|
||||
if (!LightningService.instance) {
|
||||
LightningService.instance = this
|
||||
init({
|
||||
appName: 'Jumble',
|
||||
showBalance: false
|
||||
})
|
||||
onConnected((provider) => {
|
||||
this.provider = provider
|
||||
})
|
||||
onDisconnected(() => {
|
||||
this.provider = null
|
||||
})
|
||||
}
|
||||
return LightningService.instance
|
||||
}
|
||||
|
||||
async zap(
|
||||
sender: string,
|
||||
recipient: string,
|
||||
sats: number,
|
||||
comment: string,
|
||||
eventId?: string,
|
||||
closeOuterModel?: () => void
|
||||
): Promise<{ preimage: string; invoice: string }> {
|
||||
if (!client.signer) {
|
||||
throw new Error('You need to be logged in to zap')
|
||||
}
|
||||
|
||||
const [profile, receiptRelayList, senderRelayList] = await Promise.all([
|
||||
client.fetchProfile(recipient, true),
|
||||
client.fetchRelayList(recipient),
|
||||
sender
|
||||
? client.fetchRelayList(sender)
|
||||
: Promise.resolve({ read: BIG_RELAY_URLS, write: BIG_RELAY_URLS })
|
||||
])
|
||||
if (!profile) {
|
||||
throw new Error('Recipient not found')
|
||||
}
|
||||
const zapEndpoint = await this.getZapEndpoint(profile)
|
||||
if (!zapEndpoint) {
|
||||
throw new Error("Recipient's lightning address is invalid")
|
||||
}
|
||||
const { callback, lnurl } = zapEndpoint
|
||||
const amount = sats * 1000
|
||||
const zapRequestDraft = makeZapRequest({
|
||||
profile: recipient,
|
||||
event: eventId ?? null,
|
||||
amount,
|
||||
relays: receiptRelayList.read
|
||||
.slice(0, 4)
|
||||
.concat(senderRelayList.write.slice(0, 3))
|
||||
.concat(BIG_RELAY_URLS),
|
||||
comment
|
||||
})
|
||||
const zapRequest = await client.signer(zapRequestDraft)
|
||||
const zapRequestRes = await fetch(
|
||||
`${callback}?amount=${amount}&nostr=${encodeURI(JSON.stringify(zapRequest))}&lnurl=${lnurl}`
|
||||
)
|
||||
const zapRequestResBody = await zapRequestRes.json()
|
||||
if (zapRequestResBody.error) {
|
||||
throw new Error(zapRequestResBody.error)
|
||||
}
|
||||
const { pr, verify } = zapRequestResBody
|
||||
if (!pr) {
|
||||
throw new Error('Failed to create invoice')
|
||||
}
|
||||
|
||||
if (this.provider) {
|
||||
const { preimage } = await this.provider.sendPayment(pr)
|
||||
closeOuterModel?.()
|
||||
return { preimage, invoice: pr }
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
closeOuterModel?.()
|
||||
let checkPaymentInterval: ReturnType<typeof setInterval> | undefined
|
||||
let subCloser: SubCloser | undefined
|
||||
const { setPaid } = launchPaymentModal({
|
||||
invoice: pr,
|
||||
onPaid: (response) => {
|
||||
clearInterval(checkPaymentInterval)
|
||||
subCloser?.close()
|
||||
resolve({ preimage: response.preimage, invoice: pr })
|
||||
},
|
||||
onCancelled: () => {
|
||||
clearInterval(checkPaymentInterval)
|
||||
subCloser?.close()
|
||||
}
|
||||
})
|
||||
|
||||
if (verify) {
|
||||
checkPaymentInterval = setInterval(async () => {
|
||||
const invoice = new Invoice({ pr, verify })
|
||||
const paid = await invoice.verifyPayment()
|
||||
|
||||
if (paid && invoice.preimage) {
|
||||
setPaid({
|
||||
preimage: invoice.preimage
|
||||
})
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Zap],
|
||||
'#p': [recipient],
|
||||
since: dayjs().subtract(1, 'minute').unix()
|
||||
}
|
||||
if (eventId) {
|
||||
filter['#e'] = [eventId]
|
||||
}
|
||||
subCloser = client.subscribe(
|
||||
senderRelayList.write.concat(BIG_RELAY_URLS).slice(0, 4),
|
||||
filter,
|
||||
{
|
||||
onevent: (evt) => {
|
||||
const info = extractZapInfoFromReceipt(evt)
|
||||
if (!info) return
|
||||
|
||||
if (info.invoice === pr) {
|
||||
setPaid({ preimage: info.preimage ?? '' })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async getZapEndpoint(profile: TProfile): Promise<null | {
|
||||
callback: string
|
||||
lnurl: string
|
||||
}> {
|
||||
try {
|
||||
let lnurl: string = ''
|
||||
|
||||
// Some clients have incorrectly filled in the positions for lud06 and lud16
|
||||
if (!profile.lightningAddress) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (profile.lightningAddress.includes('@')) {
|
||||
const [name, domain] = profile.lightningAddress.split('@')
|
||||
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
|
||||
} else {
|
||||
const { words } = bech32.decode(profile.lightningAddress, 1000)
|
||||
const data = bech32.fromWords(words)
|
||||
lnurl = utf8Decoder.decode(data)
|
||||
}
|
||||
|
||||
const res = await fetch(lnurl)
|
||||
const body = await res.json()
|
||||
|
||||
if (body.allowsNostr && body.nostrPubkey) {
|
||||
return {
|
||||
callback: body.callback,
|
||||
lnurl
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new LightningService()
|
||||
export default instance
|
||||
@@ -48,6 +48,10 @@ class LocalStorageService {
|
||||
private accounts: TAccount[] = []
|
||||
private currentAccount: TAccount | null = null
|
||||
private noteListMode: TNoteListMode = 'posts'
|
||||
private lastReadNotificationTimeMap: Record<string, number> = {}
|
||||
private defaultZapSats: number = 21
|
||||
private defaultZapComment: string = 'Zap!'
|
||||
private quickZap: boolean = false
|
||||
|
||||
constructor() {
|
||||
if (!LocalStorageService.instance) {
|
||||
@@ -75,6 +79,9 @@ class LocalStorageService {
|
||||
noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr)
|
||||
? (noteListModeStr as TNoteListMode)
|
||||
: 'posts'
|
||||
const lastReadNotificationTimeMapStr =
|
||||
window.localStorage.getItem(StorageKey.LAST_READ_NOTIFICATION_TIME_MAP) ?? '{}'
|
||||
this.lastReadNotificationTimeMap = JSON.parse(lastReadNotificationTimeMapStr)
|
||||
|
||||
const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS)
|
||||
if (!relaySetsStr) {
|
||||
@@ -103,6 +110,16 @@ class LocalStorageService {
|
||||
this.activeRelaySetId = window.localStorage.getItem(StorageKey.ACTIVE_RELAY_SET_ID) ?? null
|
||||
}
|
||||
|
||||
const defaultZapSatsStr = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_SATS)
|
||||
if (defaultZapSatsStr) {
|
||||
const num = parseInt(defaultZapSatsStr)
|
||||
if (!isNaN(num)) {
|
||||
this.defaultZapSats = num
|
||||
}
|
||||
}
|
||||
this.defaultZapComment = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_COMMENT) ?? 'Zap!'
|
||||
this.quickZap = window.localStorage.getItem(StorageKey.QUICK_ZAP) === 'true'
|
||||
|
||||
// Clean up deprecated data
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
||||
@@ -209,6 +226,45 @@ class LocalStorageService {
|
||||
this.currentAccount = act
|
||||
window.localStorage.setItem(StorageKey.CURRENT_ACCOUNT, JSON.stringify(act))
|
||||
}
|
||||
|
||||
getDefaultZapSats() {
|
||||
return this.defaultZapSats
|
||||
}
|
||||
|
||||
setDefaultZapSats(sats: number) {
|
||||
this.defaultZapSats = sats
|
||||
window.localStorage.setItem(StorageKey.DEFAULT_ZAP_SATS, sats.toString())
|
||||
}
|
||||
|
||||
getDefaultZapComment() {
|
||||
return this.defaultZapComment
|
||||
}
|
||||
|
||||
setDefaultZapComment(comment: string) {
|
||||
this.defaultZapComment = comment
|
||||
window.localStorage.setItem(StorageKey.DEFAULT_ZAP_COMMENT, comment)
|
||||
}
|
||||
|
||||
getQuickZap() {
|
||||
return this.quickZap
|
||||
}
|
||||
|
||||
setQuickZap(quickZap: boolean) {
|
||||
this.quickZap = quickZap
|
||||
window.localStorage.setItem(StorageKey.QUICK_ZAP, quickZap.toString())
|
||||
}
|
||||
|
||||
getLastReadNotificationTime(pubkey: string) {
|
||||
return this.lastReadNotificationTimeMap[pubkey] ?? 0
|
||||
}
|
||||
|
||||
setLastReadNotificationTime(pubkey: string, time: number) {
|
||||
this.lastReadNotificationTimeMap[pubkey] = time
|
||||
window.localStorage.setItem(
|
||||
StorageKey.LAST_READ_NOTIFICATION_TIME_MAP,
|
||||
JSON.stringify(this.lastReadNotificationTimeMap)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new LocalStorageService()
|
||||
|
||||
@@ -139,7 +139,7 @@ class RelayInfoService {
|
||||
relayInfos.forEach((relayInfo) => this.addRelayInfo(relayInfo))
|
||||
this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys())
|
||||
|
||||
const loadFromInternet = async () => {
|
||||
const loadFromInternet = async (slowFetch: boolean = true) => {
|
||||
let until: number = Math.round(Date.now() / 1000)
|
||||
const since = until - 60 * 60 * 48
|
||||
|
||||
@@ -149,23 +149,28 @@ class RelayInfoService {
|
||||
kinds: [30166],
|
||||
since,
|
||||
until,
|
||||
limit: 1000
|
||||
limit: slowFetch ? 100 : 1000
|
||||
})
|
||||
const events = relayInfoEvents.sort((a, b) => b.created_at - a.created_at)
|
||||
if (events.length === 0) {
|
||||
break
|
||||
}
|
||||
await Promise.allSettled(events.map((event) => indexedDb.putRelayInfoEvent(event)))
|
||||
for (const event of events) {
|
||||
await indexedDb.putRelayInfoEvent(event)
|
||||
const relayInfo = formatRelayInfoEvents([event])[0]
|
||||
await this.addRelayInfo(relayInfo)
|
||||
}
|
||||
until = events[events.length - 1].created_at - 1
|
||||
const relayInfos = formatRelayInfoEvents(events)
|
||||
relayInfos.forEach((relayInfo) => this.addRelayInfo(relayInfo))
|
||||
if (slowFetch) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys())
|
||||
}
|
||||
if (localRelayInfos.length === 0) {
|
||||
await loadFromInternet()
|
||||
await loadFromInternet(false)
|
||||
} else {
|
||||
loadFromInternet()
|
||||
setTimeout(loadFromInternet, 1000 * 20) // 20 seconds
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user