nappup 1.9.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -8
- package/src/index.js +130 -626
- package/src/services/blossom-upload.js +3 -2
- package/src/services/irfs-upload.js +81 -106
- package/src/services/nostr-signer.js +1 -1
- package/src/services/site-manifest.js +216 -0
- package/bin/GEMINI.md +0 -6
- package/src/helpers/GEMINI.md +0 -12
- package/src/helpers/bech32.js +0 -131
- package/src/helpers/nip19.js +0 -112
- package/src/services/GEMINI.md +0 -9
- package/src/services/base93-decoder.js +0 -107
- package/src/services/base93-encoder.js +0 -96
package/src/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import NMMR from 'nmmr'
|
|
2
|
-
import { appEncode } from '
|
|
3
|
-
import nostrRelays
|
|
2
|
+
import { appEncode } from 'libp2r2p/nip19'
|
|
3
|
+
import nostrRelays from '#services/nostr-relays.js'
|
|
4
4
|
import { getRelays } from '#helpers/signer.js'
|
|
5
5
|
import { streamToChunks, streamToText } from '#helpers/stream.js'
|
|
6
6
|
import { isNostrAppDTagSafe, GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
|
|
7
7
|
import { extractHtmlMetadata, findFavicon, findIndexFile } from '#helpers/app-metadata.js'
|
|
8
|
-
import { NAPP_CATEGORIES } from '#config/napp-categories.js'
|
|
9
8
|
import { getBlossomServers, healthCheckServers, uploadFilesToBlossom } from '#services/blossom-upload.js'
|
|
10
|
-
import { uploadBinaryDataChunks
|
|
9
|
+
import { uploadBinaryDataChunks } from '#services/irfs-upload.js'
|
|
10
|
+
import { uploadSiteManifest } from '#services/site-manifest.js'
|
|
11
11
|
|
|
12
12
|
// TL;DR
|
|
13
13
|
// import publishApp from 'nappup'
|
|
@@ -47,7 +47,7 @@ export default async function (fileList, nostrSigner, opts = {}) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Publishes a site to Nostr relays and/or
|
|
50
|
+
* Publishes a site to Nostr relays and/or Blossom servers.
|
|
51
51
|
*
|
|
52
52
|
* The optional `onEvent` callback receives structured progress events.
|
|
53
53
|
* Every event has `type` (string) and `progress` (0–100 integer).
|
|
@@ -56,27 +56,31 @@ export default async function (fileList, nostrSigner, opts = {}) {
|
|
|
56
56
|
* 'init' — { totalFiles, totalSteps, dTag, relayCount, blossomCount }
|
|
57
57
|
* 'media-uploaded' — { mediaType: 'icon'|'key_art'|'screenshot', service: 'blossom'|'irfs'|null }
|
|
58
58
|
* 'file-uploaded' — { filename, service: 'blossom'|'irfs' }
|
|
59
|
-
* '
|
|
60
|
-
* 'manifest-published' — site manifest published
|
|
59
|
+
* 'manifest-published' — unified site manifest and app metadata published
|
|
61
60
|
* 'complete' — { napp } (terminal, progress === 100)
|
|
62
61
|
* 'error' — { error } (terminal, error is rethrown)
|
|
63
|
-
*
|
|
64
|
-
* Terminal events ('complete' or 'error') signal that no more events will follow.
|
|
65
|
-
* The 'error' event is only emitted when using the default export wrapper.
|
|
66
|
-
* Direct `toApp` callers receive the thrown error via normal async/await.
|
|
67
62
|
*/
|
|
68
|
-
export async function toApp (fileList, nostrSigner, {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
export async function toApp (fileList, nostrSigner, {
|
|
64
|
+
log = () => {}, onEvent = () => {}, dTag, channel = 'main', shouldReupload = false
|
|
65
|
+
} = {}) {
|
|
66
|
+
let steps = 0
|
|
67
|
+
let totalSteps = 1
|
|
68
|
+
const emit = (event) => {
|
|
69
|
+
try {
|
|
70
|
+
onEvent({
|
|
71
|
+
...event,
|
|
72
|
+
progress: event.type === 'complete' ? 100 : Math.round((steps / totalSteps) * 100)
|
|
73
|
+
})
|
|
74
|
+
} catch (_) {}
|
|
75
|
+
}
|
|
72
76
|
if (!nostrSigner && typeof window !== 'undefined') nostrSigner = window.nostr
|
|
73
77
|
if (!nostrSigner) throw new Error('No Nostr signer found')
|
|
74
|
-
if (typeof window !== 'undefined' && nostrSigner === window.nostr)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
if (typeof window !== 'undefined' && nostrSigner === window.nostr) nostrSigner.getRelays = getRelays
|
|
79
|
+
|
|
80
|
+
const writeRelays = [...new Set((await nostrSigner.getRelays()).write
|
|
81
|
+
.map(relay => relay.trim().replace(/\/$/, '')))]
|
|
78
82
|
log(`Found ${writeRelays.length} outbox relays for pubkey ${await nostrSigner.getPublicKey()}:\n${writeRelays.join(', ')}`)
|
|
79
|
-
if (writeRelays.length
|
|
83
|
+
if (!writeRelays.length) throw new Error('No outbox relays found')
|
|
80
84
|
|
|
81
85
|
if (typeof dTag === 'string') {
|
|
82
86
|
if (!isNostrAppDTagSafe(dTag)) throw new Error('dTag must be a non-empty string with at most 260 characters')
|
|
@@ -86,63 +90,57 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
86
90
|
throw new Error(`Folder name "${folderName}" is a generic build folder. Please provide a d tag with the -d flag.`)
|
|
87
91
|
}
|
|
88
92
|
dTag = folderName
|
|
89
|
-
if (!isNostrAppDTagSafe(dTag))
|
|
93
|
+
if (!isNostrAppDTagSafe(dTag)) {
|
|
94
|
+
throw new Error('Could not derive a valid d tag from the folder name. Please provide one with the -d flag.')
|
|
95
|
+
}
|
|
90
96
|
}
|
|
91
|
-
const fileMetadata = []
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
const nappJsonFile = fileList.find(
|
|
98
|
+
const fileMetadata = []
|
|
99
|
+
const nappJsonFile = fileList.find(file => file.webkitRelativePath.split('/').slice(1).join('/') === '.well-known/napp.json')
|
|
95
100
|
let nappJson = {}
|
|
96
101
|
if (nappJsonFile) {
|
|
97
102
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
log('Failed to parse .well-known/napp.json', e)
|
|
103
|
+
nappJson = JSON.parse(await streamToText(nappJsonFile.stream()))
|
|
104
|
+
fileList = fileList.filter(file => file !== nappJsonFile)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
log('Failed to parse .well-known/napp.json', error)
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
const indexFile = findIndexFile(fileList)
|
|
107
|
-
let
|
|
108
|
-
let
|
|
109
|
-
|
|
110
|
-
if (indexFile && (!listingName || !listingSummary)) {
|
|
111
|
+
let manifestName = nappJson.name?.[0]?.[0]
|
|
112
|
+
let manifestSummary = nappJson.summary?.[0]?.[0]
|
|
113
|
+
if (indexFile && (!manifestName || !manifestSummary)) {
|
|
111
114
|
try {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
if (!
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
log('Error extracting HTML metadata:', err)
|
|
115
|
+
const { name, description } = extractHtmlMetadata(await streamToText(indexFile.stream()))
|
|
116
|
+
if (!manifestName) manifestName = name
|
|
117
|
+
if (!manifestSummary) manifestSummary = description
|
|
118
|
+
} catch (error) {
|
|
119
|
+
log('Error extracting HTML metadata:', error)
|
|
118
120
|
}
|
|
119
121
|
}
|
|
122
|
+
|
|
120
123
|
const faviconFile = findFavicon(fileList)
|
|
121
124
|
let iconMetadata
|
|
122
|
-
|
|
123
125
|
let pause = 1000
|
|
124
126
|
|
|
125
|
-
// Check for blossom servers
|
|
126
127
|
log('Checking for blossom servers...')
|
|
127
128
|
const blossomServerUrls = await getBlossomServers(nostrSigner, writeRelays)
|
|
128
129
|
let healthyBlossomServers = []
|
|
129
|
-
if (blossomServerUrls.length
|
|
130
|
+
if (blossomServerUrls.length) {
|
|
130
131
|
log(`Found ${blossomServerUrls.length} blossom servers: ${blossomServerUrls.join(', ')}`)
|
|
131
132
|
healthyBlossomServers = await healthCheckServers(blossomServerUrls, nostrSigner, { log })
|
|
132
133
|
log(`${healthyBlossomServers.length} of ${blossomServerUrls.length} blossom servers are healthy`)
|
|
133
134
|
} else {
|
|
134
135
|
log('No blossom servers configured, will use relay-based file upload (irfs)')
|
|
135
136
|
}
|
|
137
|
+
const uploadService = healthyBlossomServers.length ? 'blossom' : 'irfs'
|
|
136
138
|
|
|
137
|
-
const uploadService = healthyBlossomServers.length > 0 ? 'blossom' : 'irfs'
|
|
138
|
-
|
|
139
|
-
// Helper: upload a data URL to the chosen service, returns { rootHash, mimeType }
|
|
140
139
|
const uploadMediaFromDataUrl = async (dataUrl, mediaName) => {
|
|
141
|
-
const
|
|
142
|
-
const blob = await
|
|
143
|
-
const mimeType = blob.type
|
|
144
|
-
const
|
|
145
|
-
const filename = `${mediaName}.${extension}`
|
|
140
|
+
const response = await fetch(dataUrl)
|
|
141
|
+
const blob = await response.blob()
|
|
142
|
+
const mimeType = blob.type || 'application/octet-stream'
|
|
143
|
+
const filename = `${mediaName}.${mimeType.split('/')[1] || 'bin'}`
|
|
146
144
|
|
|
147
145
|
if (uploadService === 'blossom') {
|
|
148
146
|
const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
|
|
@@ -152,78 +150,82 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
152
150
|
shouldReupload,
|
|
153
151
|
log
|
|
154
152
|
})
|
|
155
|
-
if (failedFiles.length
|
|
156
|
-
return { rootHash: uploadedFiles[0].sha256, mimeType }
|
|
153
|
+
if (failedFiles.length) throw new Error(`Blossom upload failed for ${mediaName}`)
|
|
154
|
+
return { rootHash: uploadedFiles[0].sha256, mimeType, size: blob.size }
|
|
157
155
|
}
|
|
158
156
|
|
|
159
157
|
const nmmr = new NMMR()
|
|
160
|
-
const stream = blob.stream()
|
|
161
158
|
let chunkLength = 0
|
|
162
|
-
for await (const chunk of streamToChunks(stream, 51000)) {
|
|
159
|
+
for await (const chunk of streamToChunks(blob.stream(), 51000)) {
|
|
163
160
|
chunkLength++
|
|
164
161
|
await nmmr.append(chunk)
|
|
165
162
|
}
|
|
166
163
|
if (!chunkLength) return null
|
|
167
|
-
;({ pause } =
|
|
168
|
-
|
|
164
|
+
;({ pause } = await uploadBinaryDataChunks({
|
|
165
|
+
nmmr, signer: nostrSigner, filename, chunkLength, log, pause, shouldReupload
|
|
166
|
+
}))
|
|
167
|
+
return { rootHash: nmmr.getRoot(), mimeType, size: blob.size }
|
|
169
168
|
}
|
|
170
169
|
|
|
171
|
-
// Count media uploads for progress tracking
|
|
172
170
|
const hasIconUpload = Boolean(nappJson.icon?.[0]?.[0])
|
|
173
|
-
const keyArtEntries = nappJson.keyArt
|
|
174
|
-
const screenshotEntries = nappJson.screenshot
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
171
|
+
const keyArtEntries = Array.isArray(nappJson.keyArt) ? nappJson.keyArt : []
|
|
172
|
+
const screenshotEntries = Array.isArray(nappJson.screenshot) ? nappJson.screenshot : []
|
|
173
|
+
totalSteps = fileList.length + (hasIconUpload ? 1 : 0) + keyArtEntries.length + screenshotEntries.length + 1
|
|
174
|
+
emit({
|
|
175
|
+
type: 'init', totalFiles: fileList.length, totalSteps, dTag,
|
|
176
|
+
relayCount: writeRelays.length, blossomCount: healthyBlossomServers.length
|
|
177
|
+
})
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
if (nappJson.icon?.[0]?.[0]) {
|
|
179
|
+
if (hasIconUpload) {
|
|
181
180
|
try {
|
|
182
181
|
log('Uploading icon from napp.json')
|
|
183
182
|
iconMetadata = await uploadMediaFromDataUrl(nappJson.icon[0][0], 'icon')
|
|
184
|
-
} catch (
|
|
185
|
-
log('Failed to upload icon from napp.json',
|
|
183
|
+
} catch (error) {
|
|
184
|
+
log('Failed to upload icon from napp.json', error)
|
|
186
185
|
}
|
|
187
|
-
|
|
186
|
+
steps++
|
|
188
187
|
emit({ type: 'media-uploaded', mediaType: 'icon', service: iconMetadata ? uploadService : null })
|
|
189
188
|
}
|
|
190
189
|
|
|
191
|
-
// Upload key art from napp.json
|
|
192
190
|
const keyArtMetadata = []
|
|
193
191
|
for (const entry of keyArtEntries) {
|
|
194
|
-
const dataUrl = entry
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
192
|
+
const [dataUrl, country] = entry
|
|
193
|
+
if (dataUrl) {
|
|
194
|
+
try {
|
|
195
|
+
log(`Uploading key art from napp.json${country ? ` (${country})` : ''}`)
|
|
196
|
+
const uploaded = await uploadMediaFromDataUrl(dataUrl, 'key_art')
|
|
197
|
+
if (uploaded) keyArtMetadata.push({ ...uploaded, country })
|
|
198
|
+
} catch (error) {
|
|
199
|
+
log('Failed to upload key art from napp.json', error)
|
|
200
|
+
}
|
|
203
201
|
}
|
|
204
|
-
|
|
205
|
-
emit({
|
|
202
|
+
steps++
|
|
203
|
+
emit({
|
|
204
|
+
type: 'media-uploaded', mediaType: 'key_art',
|
|
205
|
+
service: dataUrl && keyArtMetadata.length ? uploadService : null
|
|
206
|
+
})
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
// Upload screenshots from napp.json
|
|
209
209
|
const screenshotMetadata = []
|
|
210
210
|
for (const entry of screenshotEntries) {
|
|
211
|
-
const dataUrl = entry
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
211
|
+
const [dataUrl, country] = entry
|
|
212
|
+
if (dataUrl) {
|
|
213
|
+
try {
|
|
214
|
+
log(`Uploading screenshot from napp.json${country ? ` (${country})` : ''}`)
|
|
215
|
+
const uploaded = await uploadMediaFromDataUrl(dataUrl, 'screenshot')
|
|
216
|
+
if (uploaded) screenshotMetadata.push({ ...uploaded, country })
|
|
217
|
+
} catch (error) {
|
|
218
|
+
log('Failed to upload screenshot from napp.json', error)
|
|
219
|
+
}
|
|
220
220
|
}
|
|
221
|
-
|
|
222
|
-
emit({
|
|
221
|
+
steps++
|
|
222
|
+
emit({
|
|
223
|
+
type: 'media-uploaded', mediaType: 'screenshot',
|
|
224
|
+
service: dataUrl && screenshotMetadata.length ? uploadService : null
|
|
225
|
+
})
|
|
223
226
|
}
|
|
224
227
|
|
|
225
228
|
log(`Processing ${fileList.length} files`)
|
|
226
|
-
|
|
227
229
|
if (uploadService === 'blossom') {
|
|
228
230
|
const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
|
|
229
231
|
fileList,
|
|
@@ -232,70 +234,62 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
232
234
|
shouldReupload,
|
|
233
235
|
log
|
|
234
236
|
})
|
|
235
|
-
|
|
236
|
-
if (failedFiles.length > 0) {
|
|
237
|
-
throw new Error(`${failedFiles.length} file(s) failed to upload to blossom`)
|
|
238
|
-
}
|
|
237
|
+
if (failedFiles.length) throw new Error(`${failedFiles.length} file(s) failed to upload to blossom`)
|
|
239
238
|
|
|
240
239
|
for (const uploaded of uploadedFiles) {
|
|
241
|
-
|
|
240
|
+
const metadata = {
|
|
242
241
|
rootHash: uploaded.sha256,
|
|
243
242
|
filename: uploaded.filename,
|
|
244
|
-
mimeType: uploaded.mimeType
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (faviconFile && uploaded.file === faviconFile) {
|
|
248
|
-
iconMetadata = {
|
|
249
|
-
rootHash: uploaded.sha256,
|
|
250
|
-
mimeType: uploaded.mimeType
|
|
251
|
-
}
|
|
243
|
+
mimeType: uploaded.mimeType,
|
|
244
|
+
size: uploaded.size
|
|
252
245
|
}
|
|
253
|
-
|
|
254
|
-
|
|
246
|
+
fileMetadata.push(metadata)
|
|
247
|
+
if (faviconFile && uploaded.file === faviconFile) iconMetadata = { ...metadata }
|
|
248
|
+
steps++
|
|
255
249
|
emit({ type: 'file-uploaded', filename: uploaded.filename, service: 'blossom' })
|
|
256
250
|
}
|
|
257
251
|
} else {
|
|
258
252
|
for (const file of fileList) {
|
|
259
253
|
const nmmr = new NMMR()
|
|
260
|
-
const stream = file.stream()
|
|
261
|
-
|
|
262
254
|
let chunkLength = 0
|
|
263
|
-
for await (const chunk of streamToChunks(stream, 51000)) {
|
|
255
|
+
for await (const chunk of streamToChunks(file.stream(), 51000)) {
|
|
264
256
|
chunkLength++
|
|
265
257
|
await nmmr.append(chunk)
|
|
266
258
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
rootHash: nmmr.getRoot(),
|
|
274
|
-
filename,
|
|
275
|
-
mimeType: file.type || 'application/octet-stream'
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
if (faviconFile && file === faviconFile) {
|
|
279
|
-
iconMetadata = {
|
|
280
|
-
rootHash: nmmr.getRoot(),
|
|
281
|
-
mimeType: file.type || 'application/octet-stream'
|
|
282
|
-
}
|
|
283
|
-
}
|
|
259
|
+
// Empty IRFS blobs deliberately have no chunks and no manifest reference.
|
|
260
|
+
if (!chunkLength) {
|
|
261
|
+
steps++
|
|
262
|
+
emit({ type: 'file-uploaded', filename: file.webkitRelativePath.split('/').slice(1).join('/'), service: 'irfs' })
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
284
265
|
|
|
285
|
-
|
|
286
|
-
|
|
266
|
+
const filename = file.webkitRelativePath.split('/').slice(1).join('/')
|
|
267
|
+
log(`Uploading ${chunkLength} file parts of ${filename}`)
|
|
268
|
+
;({ pause } = await uploadBinaryDataChunks({
|
|
269
|
+
nmmr, signer: nostrSigner, filename, chunkLength, log, pause, shouldReupload
|
|
270
|
+
}))
|
|
271
|
+
const metadata = {
|
|
272
|
+
rootHash: nmmr.getRoot(),
|
|
273
|
+
filename,
|
|
274
|
+
mimeType: file.type || 'application/octet-stream',
|
|
275
|
+
size: file.size
|
|
287
276
|
}
|
|
277
|
+
fileMetadata.push(metadata)
|
|
278
|
+
if (faviconFile && file === faviconFile) iconMetadata = { ...metadata }
|
|
279
|
+
steps++
|
|
280
|
+
emit({ type: 'file-uploaded', filename, service: 'irfs' })
|
|
288
281
|
}
|
|
289
282
|
}
|
|
290
283
|
|
|
291
|
-
log(`Uploading
|
|
292
|
-
|
|
284
|
+
log(`Uploading unified site manifest ${dTag}`)
|
|
285
|
+
const manifest = await uploadSiteManifest({
|
|
293
286
|
dTag,
|
|
294
287
|
channel,
|
|
295
|
-
|
|
288
|
+
fileMetadata,
|
|
289
|
+
name: manifestName,
|
|
296
290
|
nameLang: nappJson.name?.[0]?.[1],
|
|
297
291
|
isNameAuto: !nappJson.name?.[0]?.[0],
|
|
298
|
-
summary:
|
|
292
|
+
summary: manifestSummary,
|
|
299
293
|
summaryLang: nappJson.summary?.[0]?.[1],
|
|
300
294
|
isSummaryAuto: !nappJson.summary?.[0]?.[0],
|
|
301
295
|
icon: iconMetadata,
|
|
@@ -305,7 +299,6 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
305
299
|
screenshots: screenshotMetadata,
|
|
306
300
|
uploadService,
|
|
307
301
|
signer: nostrSigner,
|
|
308
|
-
writeRelays,
|
|
309
302
|
log,
|
|
310
303
|
pause,
|
|
311
304
|
shouldReupload,
|
|
@@ -313,505 +306,16 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
313
306
|
countries: nappJson.country,
|
|
314
307
|
categories: nappJson.category,
|
|
315
308
|
hashtags: nappJson.hashtag
|
|
316
|
-
})
|
|
317
|
-
_steps++
|
|
318
|
-
emit({ type: 'listing-published' })
|
|
319
|
-
|
|
320
|
-
log(`Uploading site manifest ${dTag}`)
|
|
321
|
-
const manifest = await uploadSiteManifest({ dTag, channel, fileMetadata, uploadService, signer: nostrSigner, pause, shouldReupload, log })
|
|
309
|
+
})
|
|
322
310
|
|
|
323
311
|
const appEntity = appEncode({
|
|
324
|
-
dTag: manifest.tags.find(
|
|
312
|
+
dTag: manifest.tags.find(tag => tag[0] === 'd')[1],
|
|
325
313
|
pubkey: manifest.pubkey,
|
|
326
314
|
relays: [],
|
|
327
315
|
kind: manifest.kind
|
|
328
316
|
})
|
|
329
|
-
|
|
317
|
+
steps++
|
|
330
318
|
emit({ type: 'manifest-published' })
|
|
331
|
-
|
|
332
319
|
log(`Visit at https://44billion.net/${appEntity}`)
|
|
333
320
|
emit({ type: 'complete', napp: appEntity })
|
|
334
321
|
}
|
|
335
|
-
|
|
336
|
-
async function uploadSiteManifest ({ dTag, channel, fileMetadata, uploadService, signer, pause = 0, shouldReupload = false, log = () => {} }) {
|
|
337
|
-
const kind = {
|
|
338
|
-
main: 35128, // stable
|
|
339
|
-
next: 35129, // insider
|
|
340
|
-
draft: 35130 // vibe coded preview
|
|
341
|
-
}[channel] ?? 35128
|
|
342
|
-
|
|
343
|
-
const pathTags = fileMetadata.map(v => ['path', v.filename, v.rootHash])
|
|
344
|
-
const tags = [
|
|
345
|
-
['d', dTag],
|
|
346
|
-
...pathTags,
|
|
347
|
-
['service', uploadService]
|
|
348
|
-
]
|
|
349
|
-
|
|
350
|
-
const writeRelays = [...new Set([...(await signer.getRelays()).write, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
|
|
351
|
-
|
|
352
|
-
let mostRecentEvent
|
|
353
|
-
const events = (await nostrRelays.getEvents({
|
|
354
|
-
kinds: [kind],
|
|
355
|
-
authors: [await signer.getPublicKey()],
|
|
356
|
-
'#d': [dTag],
|
|
357
|
-
limit: 1
|
|
358
|
-
}, writeRelays)).result
|
|
359
|
-
|
|
360
|
-
if (events.length > 0) {
|
|
361
|
-
events.sort((a, b) => {
|
|
362
|
-
if (b.created_at !== a.created_at) return b.created_at - a.created_at
|
|
363
|
-
if (a.id < b.id) return -1
|
|
364
|
-
if (a.id > b.id) return 1
|
|
365
|
-
return 0
|
|
366
|
-
})
|
|
367
|
-
mostRecentEvent = events[0]
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (!shouldReupload && mostRecentEvent) {
|
|
371
|
-
const recentPathTags = mostRecentEvent.tags
|
|
372
|
-
.filter(t => t[0] === 'path' && t[1] !== '.well-known/napp.json')
|
|
373
|
-
.sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
|
|
374
|
-
|
|
375
|
-
const currentPathTags = pathTags
|
|
376
|
-
.filter(t => t[1] !== '.well-known/napp.json')
|
|
377
|
-
.sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
|
|
378
|
-
|
|
379
|
-
const recentServiceTag = mostRecentEvent.tags.find(t => t[0] === 'service')
|
|
380
|
-
const serviceChanged = recentServiceTag?.[1] !== uploadService
|
|
381
|
-
|
|
382
|
-
const isSame = !serviceChanged && currentPathTags.length === recentPathTags.length && currentPathTags.every((t, i) => {
|
|
383
|
-
const rt = recentPathTags[i]
|
|
384
|
-
return rt.length >= 3 && rt[1] === t[1] && rt[2] === t[2]
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
if (isSame) {
|
|
388
|
-
log(`Site manifest based on ${pathTags.length} files is up-to-date (id: ${mostRecentEvent.id} - created_at: ${new Date(mostRecentEvent.created_at * 1000).toISOString()})`)
|
|
389
|
-
|
|
390
|
-
const matchingEvents = events.filter(e => e.id === mostRecentEvent.id)
|
|
391
|
-
const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
|
|
392
|
-
const missingRelays = writeRelays.filter(r => !coveredRelays.has(r))
|
|
393
|
-
|
|
394
|
-
if (missingRelays.length === 0) return mostRecentEvent
|
|
395
|
-
|
|
396
|
-
log(`Re-uploading existing site manifest event to ${missingRelays.length} missing relays (out of ${writeRelays.length})`)
|
|
397
|
-
await throttledSendEvent(mostRecentEvent, missingRelays, { pause, trailingPause: true, log, minSuccessfulRelays: 0 })
|
|
398
|
-
return mostRecentEvent
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const createdAt = Math.floor(Date.now() / 1000)
|
|
403
|
-
let effectiveCreatedAt = (mostRecentEvent && mostRecentEvent.created_at >= createdAt) ? mostRecentEvent.created_at + 1 : createdAt
|
|
404
|
-
const maxCreatedAt = createdAt + 172800 // 2 days ahead
|
|
405
|
-
if (effectiveCreatedAt > maxCreatedAt) effectiveCreatedAt = maxCreatedAt
|
|
406
|
-
|
|
407
|
-
const siteManifest = {
|
|
408
|
-
kind,
|
|
409
|
-
tags,
|
|
410
|
-
content: '',
|
|
411
|
-
created_at: effectiveCreatedAt
|
|
412
|
-
}
|
|
413
|
-
const event = await signer.signEvent(siteManifest)
|
|
414
|
-
await throttledSendEvent(event, writeRelays, { pause, trailingPause: true, log })
|
|
415
|
-
return event
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
async function maybeUploadAppListing ({
|
|
419
|
-
dTag,
|
|
420
|
-
channel,
|
|
421
|
-
name,
|
|
422
|
-
nameLang,
|
|
423
|
-
isNameAuto,
|
|
424
|
-
summary,
|
|
425
|
-
summaryLang,
|
|
426
|
-
isSummaryAuto,
|
|
427
|
-
icon,
|
|
428
|
-
isIconAuto,
|
|
429
|
-
descriptions,
|
|
430
|
-
keyArt,
|
|
431
|
-
screenshots,
|
|
432
|
-
uploadService,
|
|
433
|
-
signer,
|
|
434
|
-
writeRelays,
|
|
435
|
-
log,
|
|
436
|
-
pause,
|
|
437
|
-
shouldReupload,
|
|
438
|
-
self,
|
|
439
|
-
countries,
|
|
440
|
-
categories,
|
|
441
|
-
hashtags
|
|
442
|
-
}) {
|
|
443
|
-
const trimmedName = typeof name === 'string' ? name.trim() : ''
|
|
444
|
-
const trimmedSummary = typeof summary === 'string' ? summary.trim() : ''
|
|
445
|
-
const iconRootHash = icon?.rootHash
|
|
446
|
-
const iconMimeType = icon?.mimeType
|
|
447
|
-
const hasMetadata = Boolean(trimmedName) || Boolean(trimmedSummary) || Boolean(iconRootHash) ||
|
|
448
|
-
Boolean(self) || (countries && countries.length > 0) || (categories && categories.length > 0) || (hashtags && hashtags.length > 0) ||
|
|
449
|
-
(descriptions && descriptions.length > 0) || (keyArt && keyArt.length > 0) || (screenshots && screenshots.length > 0)
|
|
450
|
-
|
|
451
|
-
const relays = [...new Set([...writeRelays, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
|
|
452
|
-
|
|
453
|
-
const previousResult = await getPreviousAppListing(dTag, relays, signer, channel)
|
|
454
|
-
const previous = previousResult?.previous
|
|
455
|
-
if (!previous && !hasMetadata) {
|
|
456
|
-
if (shouldReupload) log('Skipping app listing event upload: No previous event found and no metadata provided.')
|
|
457
|
-
return { pause }
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const publishListing = async (event) => {
|
|
461
|
-
const signedEvent = await signer.signEvent(event)
|
|
462
|
-
return await throttledSendEvent(signedEvent, relays, { pause, log, trailingPause: true })
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const createdAt = Math.floor(Date.now() / 1000)
|
|
466
|
-
const kind = {
|
|
467
|
-
main: 37348,
|
|
468
|
-
next: 37349,
|
|
469
|
-
draft: 37350
|
|
470
|
-
}[channel] ?? 37348
|
|
471
|
-
|
|
472
|
-
// Helper to push media-related tags (icon, key_art, screenshot, service)
|
|
473
|
-
const pushMediaTags = (tags) => {
|
|
474
|
-
let hasMedia = false
|
|
475
|
-
|
|
476
|
-
if (iconRootHash && iconMimeType) {
|
|
477
|
-
tags.push(['icon', iconRootHash, iconMimeType])
|
|
478
|
-
if (isIconAuto) tags.push(['auto', 'icon'])
|
|
479
|
-
hasMedia = true
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (keyArt && keyArt.length > 0) {
|
|
483
|
-
for (const ka of keyArt) {
|
|
484
|
-
const row = ['key_art', ka.rootHash, ka.mimeType]
|
|
485
|
-
if (ka.country) row.push(ka.country)
|
|
486
|
-
tags.push(row)
|
|
487
|
-
}
|
|
488
|
-
hasMedia = true
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
if (screenshots && screenshots.length > 0) {
|
|
492
|
-
for (const ss of screenshots) {
|
|
493
|
-
const row = ['screenshot', ss.rootHash, ss.mimeType]
|
|
494
|
-
if (ss.country) row.push(ss.country)
|
|
495
|
-
tags.push(row)
|
|
496
|
-
}
|
|
497
|
-
hasMedia = true
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
if (hasMedia) tags.push(['service', uploadService])
|
|
501
|
-
|
|
502
|
-
return { hasIcon: Boolean(iconRootHash && iconMimeType) }
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
if (!previous) {
|
|
506
|
-
const tags = [
|
|
507
|
-
['d', dTag]
|
|
508
|
-
]
|
|
509
|
-
|
|
510
|
-
if (countries && countries.length > 0) {
|
|
511
|
-
countries.forEach(c => tags.push(['c', c]))
|
|
512
|
-
} else {
|
|
513
|
-
tags.push(['c', '*'])
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
if (self) tags.push(['self', self])
|
|
517
|
-
|
|
518
|
-
if (categories) {
|
|
519
|
-
let count = 0
|
|
520
|
-
for (const [cat, subcats] of categories) {
|
|
521
|
-
if (count >= 3) break
|
|
522
|
-
if (Array.isArray(subcats)) {
|
|
523
|
-
for (const sub of subcats) {
|
|
524
|
-
if (count >= 3) break
|
|
525
|
-
if (NAPP_CATEGORIES[cat] && NAPP_CATEGORIES[cat].includes(sub)) {
|
|
526
|
-
tags.push(['l', `napp.${cat}:${sub}`])
|
|
527
|
-
count++
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
if (hashtags) {
|
|
535
|
-
hashtags.slice(0, 3).forEach(([tag, label]) => {
|
|
536
|
-
const t = tag.replace(/\s/g, '').toLowerCase()
|
|
537
|
-
const row = ['t', t]
|
|
538
|
-
if (label) row.push(label)
|
|
539
|
-
tags.push(row)
|
|
540
|
-
})
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const { hasIcon } = pushMediaTags(tags)
|
|
544
|
-
|
|
545
|
-
let hasName = false
|
|
546
|
-
if (trimmedName) {
|
|
547
|
-
hasName = true
|
|
548
|
-
const row = ['name', trimmedName]
|
|
549
|
-
if (nameLang) row.push(nameLang)
|
|
550
|
-
tags.push(row)
|
|
551
|
-
if (isNameAuto) tags.push(['auto', 'name'])
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
if (trimmedSummary) {
|
|
555
|
-
const row = ['summary', trimmedSummary]
|
|
556
|
-
if (summaryLang) row.push(summaryLang)
|
|
557
|
-
tags.push(row)
|
|
558
|
-
if (isSummaryAuto) tags.push(['auto', 'summary'])
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
if (descriptions) {
|
|
562
|
-
for (const [text, lang] of descriptions) {
|
|
563
|
-
if (text) {
|
|
564
|
-
const row = ['description', text]
|
|
565
|
-
if (lang) row.push(lang)
|
|
566
|
-
tags.push(row)
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
if (!hasIcon || !hasName) {
|
|
572
|
-
log(`Skipping app listing event creation: Missing required metadata.${!hasName ? ' Name is missing.' : ''}${!hasIcon ? ' Icon is missing.' : ''}`)
|
|
573
|
-
return { pause }
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
return await publishListing({
|
|
577
|
-
kind,
|
|
578
|
-
tags,
|
|
579
|
-
content: '',
|
|
580
|
-
created_at: createdAt
|
|
581
|
-
})
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const tags = Array.isArray(previous.tags)
|
|
585
|
-
? previous.tags.map(tag => (Array.isArray(tag) ? [...tag] : tag))
|
|
586
|
-
: []
|
|
587
|
-
let changed = false
|
|
588
|
-
|
|
589
|
-
// Helper to remove tags by key
|
|
590
|
-
const removeTags = (key) => {
|
|
591
|
-
let idx
|
|
592
|
-
while ((idx = tags.findIndex(t => Array.isArray(t) && t[0] === key)) !== -1) {
|
|
593
|
-
tags.splice(idx, 1)
|
|
594
|
-
changed = true
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Helper to remove 'l' tags with specific prefix
|
|
599
|
-
const removeLTags = (prefix) => {
|
|
600
|
-
let idx
|
|
601
|
-
while ((idx = tags.findIndex(t => Array.isArray(t) && t[0] === 'l' && t[1].startsWith(prefix))) !== -1) {
|
|
602
|
-
tags.splice(idx, 1)
|
|
603
|
-
changed = true
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Update self
|
|
608
|
-
if (self) {
|
|
609
|
-
removeTags('self')
|
|
610
|
-
tags.push(['self', self])
|
|
611
|
-
changed = true
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// Update countries
|
|
615
|
-
if (countries) {
|
|
616
|
-
removeTags('c')
|
|
617
|
-
if (countries.length === 0) {
|
|
618
|
-
tags.push(['c', '*'])
|
|
619
|
-
} else {
|
|
620
|
-
countries.forEach(c => tags.push(['c', c]))
|
|
621
|
-
}
|
|
622
|
-
changed = true
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Update categories
|
|
626
|
-
if (categories) {
|
|
627
|
-
removeLTags('napp.')
|
|
628
|
-
let count = 0
|
|
629
|
-
for (const [cat, subcats] of categories) {
|
|
630
|
-
if (count >= 3) break
|
|
631
|
-
if (Array.isArray(subcats)) {
|
|
632
|
-
for (const sub of subcats) {
|
|
633
|
-
if (count >= 3) break
|
|
634
|
-
if (NAPP_CATEGORIES[cat] && NAPP_CATEGORIES[cat].includes(sub)) {
|
|
635
|
-
tags.push(['l', `napp.${cat}:${sub}`])
|
|
636
|
-
count++
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
changed = true
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// Update hashtags
|
|
645
|
-
if (hashtags) {
|
|
646
|
-
removeTags('t')
|
|
647
|
-
hashtags.slice(0, 3).forEach(([tag, label]) => {
|
|
648
|
-
const t = tag.replace(/\s/g, '').toLowerCase()
|
|
649
|
-
const row = ['t', t]
|
|
650
|
-
if (label) row.push(label)
|
|
651
|
-
tags.push(row)
|
|
652
|
-
})
|
|
653
|
-
changed = true
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Update descriptions
|
|
657
|
-
if (descriptions) {
|
|
658
|
-
removeTags('description')
|
|
659
|
-
for (const [text, lang] of descriptions) {
|
|
660
|
-
if (text) {
|
|
661
|
-
const row = ['description', text]
|
|
662
|
-
if (lang) row.push(lang)
|
|
663
|
-
tags.push(row)
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
changed = true
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Update key art
|
|
670
|
-
if (keyArt && keyArt.length > 0) {
|
|
671
|
-
removeTags('key_art')
|
|
672
|
-
for (const ka of keyArt) {
|
|
673
|
-
const row = ['key_art', ka.rootHash, ka.mimeType]
|
|
674
|
-
if (ka.country) row.push(ka.country)
|
|
675
|
-
tags.push(row)
|
|
676
|
-
}
|
|
677
|
-
changed = true
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Update screenshots
|
|
681
|
-
if (screenshots && screenshots.length > 0) {
|
|
682
|
-
removeTags('screenshot')
|
|
683
|
-
for (const ss of screenshots) {
|
|
684
|
-
const row = ['screenshot', ss.rootHash, ss.mimeType]
|
|
685
|
-
if (ss.country) row.push(ss.country)
|
|
686
|
-
tags.push(row)
|
|
687
|
-
}
|
|
688
|
-
changed = true
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const ensureTagValue = (key, updater) => {
|
|
692
|
-
const index = tags.findIndex(tag => Array.isArray(tag) && tag[0] === key)
|
|
693
|
-
if (index === -1) {
|
|
694
|
-
const next = updater(null)
|
|
695
|
-
if (!next) return
|
|
696
|
-
tags.push(next)
|
|
697
|
-
changed = true
|
|
698
|
-
return
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
const next = updater(tags[index])
|
|
702
|
-
if (!next) return
|
|
703
|
-
if (!tags[index] || tags[index].some((value, idx) => value !== next[idx])) {
|
|
704
|
-
tags[index] = next
|
|
705
|
-
changed = true
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
ensureTagValue('d', (existing) => {
|
|
710
|
-
if (existing && existing[1] === dTag) return existing
|
|
711
|
-
return ['d', dTag]
|
|
712
|
-
})
|
|
713
|
-
|
|
714
|
-
if (!countries) {
|
|
715
|
-
ensureTagValue('c', (existing) => {
|
|
716
|
-
if (!existing) return ['c', '*']
|
|
717
|
-
const currentValue = typeof existing[1] === 'string' ? existing[1].trim() : ''
|
|
718
|
-
if (currentValue === '') return ['c', '*']
|
|
719
|
-
return existing
|
|
720
|
-
})
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
const hasAuto = (field) => tags.some(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
|
|
724
|
-
const removeAuto = (field) => {
|
|
725
|
-
const idx = tags.findIndex(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
|
|
726
|
-
if (idx !== -1) {
|
|
727
|
-
tags.splice(idx, 1)
|
|
728
|
-
changed = true
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
if (trimmedName) {
|
|
733
|
-
if (!isNameAuto || hasAuto('name')) {
|
|
734
|
-
ensureTagValue('name', (_) => {
|
|
735
|
-
const row = ['name', trimmedName]
|
|
736
|
-
if (nameLang) row.push(nameLang)
|
|
737
|
-
return row
|
|
738
|
-
})
|
|
739
|
-
if (!isNameAuto) removeAuto('name')
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (trimmedSummary) {
|
|
744
|
-
if (!isSummaryAuto || hasAuto('summary')) {
|
|
745
|
-
ensureTagValue('summary', (_) => {
|
|
746
|
-
const row = ['summary', trimmedSummary]
|
|
747
|
-
if (summaryLang) row.push(summaryLang)
|
|
748
|
-
return row
|
|
749
|
-
})
|
|
750
|
-
if (!isSummaryAuto) removeAuto('summary')
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
if (iconRootHash && iconMimeType) {
|
|
755
|
-
if (!isIconAuto || hasAuto('icon')) {
|
|
756
|
-
ensureTagValue('icon', (_) => {
|
|
757
|
-
return ['icon', iconRootHash, iconMimeType]
|
|
758
|
-
})
|
|
759
|
-
if (!isIconAuto) removeAuto('icon')
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// Update service tag if any media exists
|
|
764
|
-
const hasMedia = Boolean(iconRootHash) || (keyArt && keyArt.length > 0) || (screenshots && screenshots.length > 0)
|
|
765
|
-
if (hasMedia) {
|
|
766
|
-
ensureTagValue('service', () => ['service', uploadService])
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
if (!changed && !shouldReupload) {
|
|
770
|
-
const { storedEvents } = previousResult
|
|
771
|
-
|
|
772
|
-
const matchingEvents = storedEvents.filter(e => e.id === previous.id)
|
|
773
|
-
const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
|
|
774
|
-
const missingRelays = relays.filter(r => !coveredRelays.has(r))
|
|
775
|
-
|
|
776
|
-
if (missingRelays.length === 0) return { pause }
|
|
777
|
-
|
|
778
|
-
log(`Re-uploading existing app listing event to ${missingRelays.length} missing relays (out of ${relays.length})`)
|
|
779
|
-
return await throttledSendEvent(previous, missingRelays, { pause, log, trailingPause: true, minSuccessfulRelays: 0 })
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
let effectiveCreatedAt = (previous && previous.created_at >= createdAt) ? previous.created_at + 1 : createdAt
|
|
783
|
-
const maxCreatedAt = createdAt + 172800 // 2 days ahead
|
|
784
|
-
if (effectiveCreatedAt > maxCreatedAt) effectiveCreatedAt = maxCreatedAt
|
|
785
|
-
|
|
786
|
-
return await publishListing({
|
|
787
|
-
kind,
|
|
788
|
-
tags,
|
|
789
|
-
content: typeof previous.content === 'string' ? previous.content : '',
|
|
790
|
-
created_at: effectiveCreatedAt
|
|
791
|
-
})
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
async function getPreviousAppListing (dTagValue, relays, signer, channel) {
|
|
795
|
-
const kind = {
|
|
796
|
-
main: 37348,
|
|
797
|
-
next: 37349,
|
|
798
|
-
draft: 37350
|
|
799
|
-
}[channel] ?? 37348
|
|
800
|
-
|
|
801
|
-
const storedEvents = (await nostrRelays.getEvents({
|
|
802
|
-
kinds: [kind],
|
|
803
|
-
authors: [await signer.getPublicKey()],
|
|
804
|
-
'#d': [dTagValue],
|
|
805
|
-
limit: 1
|
|
806
|
-
}, relays)).result
|
|
807
|
-
|
|
808
|
-
if (storedEvents.length === 0) return null
|
|
809
|
-
|
|
810
|
-
storedEvents.sort((a, b) => b.created_at - a.created_at)
|
|
811
|
-
|
|
812
|
-
return {
|
|
813
|
-
previous: storedEvents[0],
|
|
814
|
-
storedEvents,
|
|
815
|
-
targetRelayCount: relays.length
|
|
816
|
-
}
|
|
817
|
-
}
|