* Add infinite scroll in feed

* Refactor Nostr fetch logic
* Fix FetchQueue getting stuck
This commit is contained in:
styppo 2023-01-16 17:49:46 +00:00
parent fbdceb6f04
commit ecbb2f98e3
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
14 changed files with 328 additions and 241 deletions

View File

@ -0,0 +1,80 @@
<template>
<div ref="button" class="async-load-button">
<q-btn
:loading="loading"
:label="noMore ? labelNoMore : label"
@click="load"
size="md"
flat
/>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AsyncLoadButton',
emits: ['loading', 'loaded'],
props: {
loadFn: {
type: Function,
required: true,
},
label: {
type: String,
default: 'Load more'
},
labelNoMore: {
type: String,
default: 'No more items. Try again?'
},
autoload: {
type: Boolean,
default: false,
}
},
data() {
return {
loading: false,
noMore: false,
observer: null,
}
},
methods: {
async load() {
if (this.loading) return
console.log('loading')
this.loading = true
this.$emit('loading')
const result = await this.loadFn()
this.noMore = !result || !result.length
this.loading = false
this.$emit('loaded', result)
}
},
mounted() {
if (this.autoload) {
this.observer = new IntersectionObserver(this.load.bind(this), {
root: null,
threshold: 0.5,
})
this.observer.observe(this.$refs.button)
}
},
unmounted() {
if (this.observer) this.observer.disconnect()
}
})
</script>
<style lang="scss" scoped>
.async-load-button button {
width: 100%;
padding: 1rem 0;
letter-spacing: 1px;
}
</style>

View File

@ -1,43 +0,0 @@
<template>
<div class="load-more">
<q-btn
:loading="loading"
:label="reachedEnd ? 'reached end' : label"
:disable="reachedEnd"
@click="$emit('click')"
size="md"
flat
/>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'BaseButtonLoadMore',
emits: ['click'],
props: {
loading: {
type: Boolean,
default: false,
},
reachedEnd: {
type: Boolean,
default: false
},
label: {
type: String,
default: 'load more'
}
},
})
</script>
<style lang="scss" scoped>
.load-more button {
width: 100%;
padding: 1rem 0;
letter-spacing: 1px;
}
</style>

View File

@ -128,7 +128,7 @@ export default {
},
},
mounted() {
const updateInterval = Date.now() / 1000 - this.note.createdAt >= 3600 // 1h
const updateInterval = DateUtils.now() - this.note.createdAt >= 3600 // 1h
? 3600 // 1h
: 60 // 1m
this.refreshTimer = setInterval(() => this.refreshCounter++, updateInterval * 1000)

View File

@ -5,21 +5,19 @@ export default class FetchQueue extends Observable {
super()
this.client = client
this.subId = subId
this.sub = null
this.fnGetId = fnGetId
this.fnCreateFilter = fnCreateFilter
this.throttle = opts.throttle || 250
this.batchSize = opts.batchSize || 50
this.retryDelay = opts.retryDelay || 5000
this.maxRetries = opts.maxRetries || 3
this.batchSize = opts.batchSize || 250
this.timeout = opts.timeout || 3000
this.maxAttempts = opts.maxAttempts || 2
this.queue = {}
this.failed = {}
this.fetching = false
this.fetchQueued = false
this.retryInterval = null
// XXX
setInterval(() => this.failed = {}, 10000)
this.fetchTimeout = null
}
add(id) {
@ -37,16 +35,19 @@ export default class FetchQueue extends Observable {
fetch() {
this.fetchQueued = false
if (this.retryInterval) clearInterval(this.retryInterval)
if (this.fetchTimeout) clearTimeout(this.fetchTimeout)
const ids = Object.keys(this.queue).slice(0, this.batchSize)
if (!ids.length) return
if (!ids.length) {
this.fetching = false
return
}
// Remove ids that we have tried too many times.
const filteredIds = []
for (const id of ids) {
this.queue[id]++
if (this.queue[id] > this.maxRetries) {
if (this.queue[id] > this.maxAttempts) {
console.warn(`Failed to fetch ${this.subId} ${id}`)
this.failed[id] = true
delete this.queue[id]
@ -55,12 +56,15 @@ export default class FetchQueue extends Observable {
}
}
if (!filteredIds.length) return
if (!filteredIds.length) {
this.fetching = false
return
}
// console.log(`Fetching ${filteredIds.length}/${Object.keys(this.queue).length} ${this.subId}s`, ids)
console.log(`Fetching ${this.subId}s ${filteredIds.length}/${Object.keys(this.queue).length} `)
this.fetching = true
this.retryInterval = setInterval(this.fetch.bind(this), this.retryDelay)
this.fetchTimeout = setTimeout(this.fetch.bind(this), this.timeout)
// XXX Needed for some relays?
//this.client.unsubscribe(this.subId)
@ -78,19 +82,19 @@ export default class FetchQueue extends Observable {
this.emit('event', event, relay)
if (Object.keys(this.queue).length === 0) {
if (this.retryInterval) clearInterval(this.retryInterval)
if (this.fetchTimeout) clearTimeout(this.fetchTimeout)
this.fetching = false
sub.close()
} else if (filteredIds.length === 0) {
this.fetch()
}
})
sub.on('complete', () => {
sub.on('end', () => {
if (this.fetching && Object.keys(this.queue).length > 0) {
this.fetch()
} else {
console.log('[COMPLETE]', this)
if (this.retryInterval) clearInterval(this.retryInterval)
if (this.fetchTimeout) clearTimeout(this.fetchTimeout)
this.fetching = false
sub.close()
}

View File

@ -25,8 +25,8 @@ export default class NostrClient {
return this.connectedRelays().some(relay => relay.url === url)
}
subscribe(filters, subId = null) {
return this.pool.subscribe(filters, subId)
subscribe(filters, subId = null, closeAfter = CloseAfter.NEVER) {
return this.pool.subscribe(filters, subId, closeAfter)
}
unsubscribe(subId) {
@ -37,47 +37,45 @@ export default class NostrClient {
return this.pool.publish(event)
}
// fetchSingle(filters) {
// const filtersWithLimit = Object.assign({}, filters, {limit: 1})
// return new Promise(resolve => {
// const sub = this.pool.subscribe(filters)
// sub.on('event')
// this.client.subscribe(
// filtersWithLimit,
// (event, relay) => {
// resolve(this.addEvent(event, relay))
// },
// {
// closeAfter: 'single'
// }
// )
// })
// }
fetchMultiple(filters, limit = 100, timeout = 5000) {
fetch(filters, opts = {}) {
return new Promise(resolve => {
const objects = {}
const filtersWithLimit = Object.assign({}, filters, {limit})
const sub = this.pool.subscribe(filtersWithLimit, null, CloseAfter.EOSE)
const events = {}
const sub = this.pool.subscribe(filters, opts.subId, CloseAfter.EOSE)
const timer = setTimeout(() => {
sub.close()
resolve(Object.values(objects))
}, timeout)
resolve(Object.values(events))
}, opts.timeout || 4000)
sub.on('event', event => {
objects[event.id] = event
events[event.id] = event
})
sub.on('complete', () => {
sub.on('end', () => {
sub.close()
clearTimeout(timer)
resolve(Object.values(objects))
resolve(Object.values(events))
})
sub.on('close', () => {
clearTimeout(timer)
resolve(Object.values(objects))
resolve(Object.values(events))
})
})
}
stream(filters, eventCallback, endCallback = () => {}, opts = {}) {
const events = {}
const sub = this.pool.subscribe(filters, opts.subId)
const timer = setTimeout(() => {
endCallback(Object.values(events))
}, opts.timeout || 5000)
sub.on('event', event => {
events[event.id] = event
})
sub.on('end', () => {
clearTimeout(timer)
endCallback(Object.values(events))
})
return sub
}
onNotice(relay, message) {
console.warn(`[NOTICE] from ${relay}: ${message}`)
}

View File

@ -8,15 +8,20 @@ import {useProfileStore} from 'src/nostr/store/ProfileStore'
import {useContactStore} from 'src/nostr/store/ContactStore'
import {useSettingsStore} from 'stores/Settings'
import {useStatStore} from 'src/nostr/store/StatStore'
import {Observable} from 'src/nostr/utils'
import {CloseAfter} from 'src/nostr/Relay'
import DateUtils from 'src/utils/DateUtils'
export const Feeds = {
GLOBAL: {
name: 'global',
filters: {
kinds: [EventKind.NOTE], // TODO Deletions
},
initialFetchSize: 100,
},
class Stream extends Observable {
constructor(sub) {
super()
this.sub = sub
sub.on('close', this.emit.bind(this, 'close'))
}
close(relay = null) {
this.sub.close(relay)
}
}
const eventQueue = (client, subId) => new FetchQueue(
@ -76,10 +81,10 @@ export const useNostrStore = defineStore('nostr', {
if (relay?.url) {
if (this.seenBy[event.id]) {
this.seenBy[event.id][relay.url] = Date.now()
this.seenBy[event.id][relay.url] = DateUtils.now()
} else {
this.seenBy[event.id] = {
[relay.url]: Date.now()
[relay.url]: DateUtils.now()
}
const stats = useStatStore()
@ -107,7 +112,8 @@ export const useNostrStore = defineStore('nostr', {
case EventKind.DELETE:
break
case EventKind.SHARE:
break
// TODO
return event
case EventKind.REACTION: {
const notes = useNoteStore()
return notes.addEvent(event)
@ -162,7 +168,7 @@ export const useNostrStore = defineStore('nostr', {
},
fetchNotesByAuthor(pubkey, limit = 100) {
return this.fetchMultiple(
return this.fetch(
{
kinds: [EventKind.NOTE],
authors: [pubkey],
@ -187,7 +193,7 @@ export const useNostrStore = defineStore('nostr', {
fetchFollowers(pubkey, opts = {}) {
const limit = opts.limit || 500
return this.fetchMultiple(
return this.fetch(
{
kinds: [EventKind.CONTACT],
'#p': [pubkey],
@ -204,7 +210,7 @@ export const useNostrStore = defineStore('nostr', {
},
fetchReactionsTo(id, limit = 500) {
return this.fetchMultiple(
return this.fetch(
{
kinds: [EventKind.REACTION],
'#e': [id],
@ -221,7 +227,7 @@ export const useNostrStore = defineStore('nostr', {
},
fetchReactionsByAuthor(pubkey, limit = 500) {
return this.fetchMultiple(
return this.fetch(
{
kinds: [EventKind.REACTION],
authors: [pubkey],
@ -230,47 +236,98 @@ export const useNostrStore = defineStore('nostr', {
)
},
async fetchMultiple(filters, limit = 100, timeout = 5000) {
const events = await this.client.fetchMultiple(filters, limit, timeout)
return events.map(event => this.addEvent(event))
async fetch(filters, opts = {}) {
return new Promise(resolve => {
const events = {}
const sub = this.client.subscribe(filters, opts.subId, CloseAfter.EOSE)
const timer = setTimeout(() => {
const values = Object.values(events)
console.log(`[TIMEOUT] fetch ${sub.subId} (${values.length})`, filters)
sub.close()
resolve(values)
}, opts.timeout || 4000)
sub.on('end', () => {
const values = Object.values(events)
console.log(`[COMPLETE] fetch ${sub.subId} (${values.length})`, filters)
sub.close()
clearTimeout(timer)
resolve(values)
})
sub.on('close', () => {
clearTimeout(timer)
resolve(Object.values(events))
})
sub.on('event', (event, relay) => {
const object = this.addEvent(event, relay)
if (!object) {
console.warn('Discarding event', event)
return
}
events[event.id] = object
})
})
},
streamThread(rootId, eventCallback, initialFetchCompleteCallback) {
return this.streamEvents(
stream(filters, opts = {}) {
let objects = {}
const sub = this.client.subscribe(filters, opts.subId)
const stream = new Stream(sub)
const timer = setTimeout(() => {
const values = Object.values(objects)
console.log(`[TIMEOUT] stream ${sub.subId} (${values.length})`, filters)
stream.emit('init', values)
objects = null
}, opts.timeout || 5000)
sub.on('end', () => {
clearTimeout(timer)
const values = Object.values(objects)
console.log(`[COMPLETE] stream ${sub.subId} (${values.length})`, filters)
stream.emit('init', values)
objects = null
})
sub.on('event', (event, relay) => {
const known = this.hasEvent(event.id)
const object = this.addEvent(event, relay)
if (!object) {
console.warn('Discarding event', event)
return
}
if (known) return
if (!objects) {
stream.emit('update', object, relay, stream)
} else {
objects[event.id] = object
}
})
return stream
},
streamThread(rootId) {
return this.stream(
{
kinds: [EventKind.NOTE],
kinds: [EventKind.NOTE, EventKind.REACTION, EventKind.SHARE],
'#e': [rootId],
limit: 500,
},
500,
eventCallback,
initialFetchCompleteCallback,
{
subId: `thread:${rootId}`,
}
)
},
streamFeed(feed, eventCallback, initialFetchCompleteCallback) {
return this.streamEvents(
feed.filters,
feed.initialFetchSize,
eventCallback,
initialFetchCompleteCallback,
{
subId: feed.name,
}
)
},
streamNotifications(pubkey, eventCallback, initialFetchCompleteCallback) {
return this.streamEvents(
streamNotifications(pubkey) {
return this.stream(
{
kinds: [EventKind.NOTE, EventKind.REACTION], // TODO SHARE, CONTACT
'#p': [pubkey],
limit: 50,
},
50,
eventCallback,
initialFetchCompleteCallback,
{
subId: `notifications:${pubkey}`,
}
@ -315,39 +372,5 @@ export const useNostrStore = defineStore('nostr', {
this.client.unsubscribe(subId)
}
},
streamEvents(filters, initialFetchSize, eventCallback, initialFetchCompleteCallback, opts) {
const filtersWithLimit = Object.assign({}, filters, {limit: initialFetchSize})
let initialFetchComplete = false
const sub = this.client.subscribe(filtersWithLimit, opts.subId || null)
const timer = setTimeout(() => {
console.log(`[TIMEOUT] ${sub.subId}, intialFetchComplete=${initialFetchComplete}`)
if (!initialFetchComplete) {
initialFetchComplete = true
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
}
}, opts.timeout || 3000)
sub.on('event', (event, relay) => {
const known = this.hasEvent(event.id)
const obj = this.addEvent(event, relay)
if (!obj || known) return
if (eventCallback) eventCallback(obj, relay)
})
sub.on('eose', (relay, subId) => {
console.log(`[EOSE] ${subId} ${relay} ${this.client.connectedRelays().length}`)
})
sub.on('complete', () => {
console.log(`[COMPLETE] ${sub.subId}, intialFetchComplete=${initialFetchComplete}`)
if (!initialFetchComplete) {
initialFetchComplete = true
clearTimeout(timer)
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
}
})
return sub
}
},
})

View File

@ -45,7 +45,7 @@ class MultiSubscription extends Observable {
if (!sub.eoseSeen) {
sub.eoseSeen = true
if (Object.values(this.subs).every(sub => sub.eoseSeen)) {
this.emit('complete', this.subId)
this.emit('end', this.subId)
}
}
}
@ -103,6 +103,7 @@ export default class ReplayPool extends Observable {
subscribe(filters, subId = null, closeAfter = CloseAfter.NEVER) {
if (!subId) subId = `sub${this.nextSubId++}`
console.log(`[SUBSCRIBE] ${subId}`, filters)
const sub = new MultiSubscription(subId, [])
sub.on('close', this.unsubscribe.bind(this, subId))
@ -123,6 +124,7 @@ export default class ReplayPool extends Observable {
unsubscribe(subId) {
const sub = this.subs[subId]
if (!sub) return
console.log(`[UNSUBSCRIBE] ${subId}`)
sub.sub.close()
delete this.subs[subId]
}

View File

@ -1,4 +1,5 @@
import {getEventHash} from 'nostr-tools'
import DateUtils from 'src/utils/DateUtils'
export const EventKind = {
METADATA: 0,
@ -71,7 +72,7 @@ export default class Event {
}
static fresh(opts) {
opts.createdAt = opts.createdAt || Math.floor(Date.now() / 1000)
opts.createdAt = opts.createdAt || DateUtils.now()
return new Event(opts)
}

View File

@ -20,7 +20,7 @@ export const useContactStore = defineStore('contact', {
const existingContacts = this.contacts[event.pubkey]
if (existingContacts && existingContacts.lastUpdatedAt >= event.createdAt) {
return
return existingContacts
}
const newContacts = []

View File

@ -31,11 +31,10 @@
<div class="feed">
<div class="load-more-container" :class="{'more-available': numUnreads}">
<ButtonLoadMore
<AsyncLoadButton
v-if="numUnreads"
:label="`Load ${numUnreads} unread`"
:loading="loading"
@click="loadUnreads"
:load-fn="loadUnreads"
:label="`Load ${numUnreads} unreads`"
/>
</div>
@ -45,11 +44,11 @@
<ListPlaceholder :count="feedItems?.length" :loading="loading" />
<!-- <ButtonLoadMore-->
<!-- :loading="loadingMore"-->
<!-- :label="items.length === feed[tab].length ? 'load another day' : 'load 100 more'"-->
<!-- @click="loadMore"-->
<!-- />-->
<AsyncLoadButton
v-if="feedItems?.length"
:load-fn="loadOlder"
autoload
/>
</div>
</q-page>
</template>
@ -60,11 +59,23 @@ import PageHeader from 'components/PageHeader.vue'
import PostEditor from 'components/CreatePost/PostEditor.vue'
import Thread from 'components/Post/Thread.vue'
import BaseIcon from 'components/BaseIcon/index.vue'
import ButtonLoadMore from 'components/ButtonLoadMore.vue'
import AsyncLoadButton from 'components/AsyncLoadButton.vue'
import ListPlaceholder from 'components/ListPlaceholder.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore, Feeds} from 'src/nostr/NostrStore'
import {useNostrStore} from 'src/nostr/NostrStore'
import Defer from 'src/utils/Defer'
import {EventKind} from 'src/nostr/model/Event'
import DateUtils from 'src/utils/DateUtils'
const Feeds = {
global: {
name: 'global',
filters: {
kinds: [EventKind.NOTE], // TODO Deletions
limit: 20,
},
},
}
const feedOrder = (a, b) => b[0].createdAt - a[0].createdAt
@ -75,10 +86,10 @@ export default defineComponent({
PostEditor,
Thread,
BaseIcon,
ButtonLoadMore,
AsyncLoadButton,
ListPlaceholder,
},
mixins: [Defer()],
mixins: [Defer(2000)],
setup() {
return {
app: useAppStore(),
@ -92,7 +103,6 @@ export default defineComponent({
selectedFeed: 'global',
loading: true,
recentlyLoaded: true,
sub: null,
}
},
computed: {
@ -114,57 +124,73 @@ export default defineComponent({
initFeed(feedId) {
if (this.feeds[feedId]) return
const filters = Feeds[feedId].filters
const stream = this.nostr.stream(
filters,
{subId: `feed:${feedId}`}
)
stream.on('init', events => {
const items = events.map(event => [event]) // TODO Single element thread
items.sort(feedOrder)
this.feeds[feedId].items = items.slice(0, filters.limit)
this.loading = false
// Wait a bit before showing the first unreads
setTimeout(() => this.recentlyLoaded = false, 5000)
})
stream.on('update', event => {
this.feeds[feedId].unreads.push([event]) // TODO Single element thread
})
this.feeds[feedId] = {
items: [],
unreads: [],
stream,
}
let initialFetchComplete = false
let initialItems = []
console.log(`subscribing to feed ${feedId}`, this.feeds[feedId])
this.sub = this.nostr.streamFeed(
Feeds[feedId.toUpperCase()],
event => {
const target = initialFetchComplete
? this.feeds[feedId].unreads
: initialItems
target.push([event]) // FIXME Single element thread
},
() => {
initialItems.sort(feedOrder)
this.feeds[feedId].items = initialItems.slice(0, 50)
initialFetchComplete = true
this.loading = false
// Wait a bit before showing the first unreads
setTimeout(() => this.recentlyLoaded = false, 5000)
}
)
},
switchFeed(feedId) {
this.initFeed(feedId)
this.selectedFeed = feedId
},
loadUnreads() {
this.loading = true
// TODO Deduplicate feed items
const items = this.feedUnreads.concat(this.feedItems)
items.sort(feedOrder)
//items.sort(feedOrder)
this.activeFeed.items = items
this.activeFeed.unreads = []
this.loading = false
// Wait a bit before showing unreads again
this.recentlyLoaded = true
setTimeout(() => this.recentlyLoaded = false, 5000)
return true
},
async loadOlder() {
const until = this.feedItems[this.feedItems.length - 1]?.[0]?.createdAt || DateUtils.now()
console.log('until', new Date(until * 1000))
const filters = Object.assign({}, Feeds[this.selectedFeed].filters, {until})
const older = await this.nostr.fetch(filters, {subId: `feed:${this.selectedFeed}-older`})
const items = older.map(event => [event]).sort(feedOrder)
console.log('got items', older)
console.log('length before', this.activeFeed.items.length)
// TODO Deduplicate feed items
this.activeFeed.items = this.feedItems.concat(items)
console.log('length after', this.activeFeed.items.length)
return older
},
},
mounted() {
this.initFeed(this.selectedFeed)
},
unmounted() {
if (this.sub) this.nostr.cancelStream(this.sub)
for (const feed of Object.values(this.feeds)) {
feed.stream.close()
}
}
})
</script>
@ -179,7 +205,7 @@ export default defineComponent({
border-bottom: $border-dark;
min-height: 6px;
}
> .load-more:last-child {
> .async-load-button:last-child {
border-bottom: 0;
}
}

View File

@ -32,30 +32,24 @@ export default {
data() {
return {
notifications: [],
stream: null,
loading: true,
}
},
methods: {
},
mounted() {
const read = []
const unread = []
this.nostr.streamNotifications(
this.app.myPubkey,
event => {
if (event.createdAt >= this.settings.notificationsLastRead) {
unread.push(event)
} else {
read.push(event)
}
},
() => {
unread.sort(NoteOrder.CREATION_DATE_DESC)
this.notifications = unread
this.loading = false
}
)
}
this.stream = this.nostr.streamNotifications(this.app.myPubkey)
this.stream.on('init', events => {
events.sort(NoteOrder.CREATION_DATE_DESC)
this.notifications = events
this.loading = false
})
this.stream.on('update', event => this.notifications.unshift(event))
},
unmounted() {
if (this.stream) this.stream.close()
},
}
</script>

View File

@ -4,6 +4,7 @@
<div ref="ancestors">
<Thread
v-if="ancestors?.length"
:thread="ancestors"
force-bottom-connector
class="ancestors"
@ -96,17 +97,14 @@ export default defineComponent({
methods: {
startStream() {
if (!this.rootId) return
this.subId = this.nostr.streamThread(
this.rootId,
() => {}, // TODO
this.buildThread.bind(this)
)
this.stream = this.nostr.streamThread(this.rootId)
this.stream.on('init', this.buildThread.bind(this))
},
cancelStream() {
if (!this.subId) return
this.nostr.cancelStream(this.subId)
this.subId = null
closeStream() {
if (!this.stream) return
this.stream.close()
this.stream = null
},
buildThread() {
@ -235,7 +233,7 @@ export default defineComponent({
},
unmounted() {
console.log('unmounted', this.subId)
this.cancelStream()
this.closeStream()
this.resizeObserver.disconnect()
}
})

View File

@ -17,6 +17,10 @@ const MONTHS = [
]
export default class DateUtils {
static now() {
return Math.floor(Date.now() / 1000)
}
static formatDate(timestamp) {
const date = new Date(timestamp * 1000)
const month = MONTHS[date.getMonth()] // TODO i18n
@ -49,8 +53,7 @@ export default class DateUtils {
}
static formatFromNowShort(timestamp) {
const now = Date.now()
const diff = Math.round(Math.max(now - (timestamp * 1000), 0) / 1000)
const diff = Math.max(DateUtils.now() - timestamp, 0)
const formatDiff = (unit, factor, offset) => Math.max(Math.floor((diff + (unit * offset)) / (unit * factor)), 1)
if (diff < 45) return `${formatDiff(1, 1, 0)}s`

View File

@ -9,9 +9,10 @@ export default class Nip05 {
try {
const res = await fetch(url)
const json = await res.json()
if (!json || !json.names) return
return json.names[user]
} catch (e) {
console.warn(`Failed to fetch NIP05 data for ${nip05Id}`, e)
//console.warn(`Failed to fetch NIP05 data for ${nip05Id}`, e)
}
}