event-metadata.ts raw
1 import { MAX_PINNED_NOTES, POLL_TYPE } from '@/constants'
2 import { Pubkey } from '@/domain'
3 import { TEmoji, TPollType, TRelayList, TRelaySet } from '@/types'
4 import { Event, kinds } from 'nostr-tools'
5 import { buildATag } from './draft-event'
6 import { getReplaceableEventIdentifier } from './event'
7 import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
8 import { generateBech32IdFromETag, getEmojiInfosFromEmojiTags, tagNameEquals } from './tag'
9 import { isOnionUrl, isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url'
10
11 export function getRelayListFromEvent(
12 event?: Event | null,
13 filterOutOnionRelays: boolean = true
14 ): TRelayList {
15 if (!event) {
16 return { write: [], read: [], originalRelays: [] }
17 }
18
19 const relayList = { write: [], read: [], originalRelays: [] } as TRelayList
20 event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
21 if (!url || !isWebsocketUrl(url)) return
22
23 const normalizedUrl = normalizeUrl(url)
24 if (!normalizedUrl) return
25
26 const scope = type === 'read' ? 'read' : type === 'write' ? 'write' : 'both'
27 relayList.originalRelays.push({ url: normalizedUrl, scope })
28
29 if (filterOutOnionRelays && isOnionUrl(normalizedUrl)) return
30
31 if (type === 'write') {
32 relayList.write.push(normalizedUrl)
33 } else if (type === 'read') {
34 relayList.read.push(normalizedUrl)
35 } else {
36 relayList.write.push(normalizedUrl)
37 relayList.read.push(normalizedUrl)
38 }
39 })
40
41 return relayList
42 }
43
44 export function getProfileFromEvent(event: Event) {
45 try {
46 const profileObj = JSON.parse(event.content)
47 const username =
48 profileObj.display_name?.trim() ||
49 profileObj.name?.trim() ||
50 profileObj.nip05?.split('@')[0]?.trim()
51
52 // Extract emojis from emoji tags according to NIP-30
53 const emojis = getEmojiInfosFromEmojiTags(event.tags)
54
55 const pk = Pubkey.tryFromString(event.pubkey)
56 return {
57 pubkey: event.pubkey,
58 npub: pk?.npub ?? '',
59 banner: profileObj.banner,
60 avatar: profileObj.picture,
61 username: username || (pk?.formatNpub(12) ?? event.pubkey.slice(0, 8)),
62 original_username: username,
63 nip05: profileObj.nip05,
64 about: profileObj.about,
65 website: profileObj.website ? normalizeHttpUrl(profileObj.website) : undefined,
66 lud06: profileObj.lud06,
67 lud16: profileObj.lud16,
68 lightningAddress: getLightningAddressFromProfile(profileObj),
69 created_at: event.created_at,
70 emojis: emojis.length > 0 ? emojis : undefined
71 }
72 } catch (err) {
73 console.error(event.content, err)
74 const pk = Pubkey.tryFromString(event.pubkey)
75 return {
76 pubkey: event.pubkey,
77 npub: pk?.npub ?? '',
78 username: pk?.formatNpub(12) ?? event.pubkey.slice(0, 8)
79 }
80 }
81 }
82
83 export function getRelaySetFromEvent(event: Event): TRelaySet {
84 const id = getReplaceableEventIdentifier(event)
85 const relayUrls = event.tags
86 .filter(tagNameEquals('relay'))
87 .map((tag) => tag[1])
88 .filter((url) => url && isWebsocketUrl(url))
89 .map((url) => normalizeUrl(url))
90
91 let name = event.tags.find(tagNameEquals('title'))?.[1]
92 if (!name) {
93 name = id
94 }
95
96 return { id, name, relayUrls, aTag: buildATag(event) }
97 }
98
99 export function getZapInfoFromEvent(receiptEvent: Event) {
100 if (receiptEvent.kind !== kinds.Zap) return null
101
102 let senderPubkey: string | undefined
103 let recipientPubkey: string | undefined
104 let originalEventId: string | undefined
105 let eventId: string | undefined
106 let invoice: string | undefined
107 let amount: number | undefined
108 let comment: string | undefined
109 let description: string | undefined
110 let preimage: string | undefined
111 try {
112 receiptEvent.tags.forEach((tag) => {
113 const [tagName, tagValue] = tag
114 switch (tagName) {
115 case 'P':
116 senderPubkey = tagValue
117 break
118 case 'p':
119 recipientPubkey = tagValue
120 break
121 case 'e':
122 originalEventId = tag[1]
123 eventId = generateBech32IdFromETag(tag)
124 break
125 case 'bolt11':
126 invoice = tagValue
127 break
128 case 'description':
129 description = tagValue
130 break
131 case 'preimage':
132 preimage = tagValue
133 break
134 }
135 })
136 if (!recipientPubkey || !invoice) return null
137 amount = invoice ? getAmountFromInvoice(invoice) : 0
138 if (description) {
139 try {
140 const zapRequest = JSON.parse(description)
141 comment = zapRequest.content
142 if (!senderPubkey) {
143 senderPubkey = zapRequest.pubkey
144 }
145 } catch {
146 // ignore
147 }
148 }
149
150 return {
151 senderPubkey,
152 recipientPubkey,
153 eventId,
154 originalEventId,
155 invoice,
156 amount,
157 comment,
158 preimage
159 }
160 } catch {
161 return null
162 }
163 }
164
165 export function getLongFormArticleMetadataFromEvent(event: Event) {
166 let title: string | undefined
167 let summary: string | undefined
168 let image: string | undefined
169 const tags = new Set<string>()
170
171 event.tags.forEach(([tagName, tagValue]) => {
172 if (tagName === 'title') {
173 title = tagValue
174 } else if (tagName === 'summary') {
175 summary = tagValue
176 } else if (tagName === 'image') {
177 image = tagValue
178 } else if (tagName === 't' && tagValue && tags.size < 6) {
179 tags.add(tagValue.toLocaleLowerCase())
180 }
181 })
182
183 if (!title) {
184 title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no title'
185 }
186
187 return { title, summary, image, tags: Array.from(tags) }
188 }
189
190 export function getLiveEventMetadataFromEvent(event: Event) {
191 let title: string | undefined
192 let summary: string | undefined
193 let image: string | undefined
194 let status: string | undefined
195 const tags = new Set<string>()
196
197 event.tags.forEach(([tagName, tagValue]) => {
198 if (tagName === 'title') {
199 title = tagValue
200 } else if (tagName === 'summary') {
201 summary = tagValue
202 } else if (tagName === 'image') {
203 image = tagValue
204 } else if (tagName === 'status') {
205 status = tagValue
206 } else if (tagName === 't' && tagValue && tags.size < 6) {
207 tags.add(tagValue.toLocaleLowerCase())
208 }
209 })
210
211 if (!title) {
212 title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no title'
213 }
214
215 return { title, summary, image, status, tags: Array.from(tags) }
216 }
217
218 export function getGroupMetadataFromEvent(event: Event) {
219 let d: string | undefined
220 let name: string | undefined
221 let about: string | undefined
222 let picture: string | undefined
223 const tags = new Set<string>()
224
225 event.tags.forEach(([tagName, tagValue]) => {
226 if (tagName === 'name') {
227 name = tagValue
228 } else if (tagName === 'about') {
229 about = tagValue
230 } else if (tagName === 'picture') {
231 picture = tagValue
232 } else if (tagName === 't' && tagValue) {
233 tags.add(tagValue.toLocaleLowerCase())
234 } else if (tagName === 'd') {
235 d = tagValue
236 }
237 })
238
239 if (!name) {
240 name = d ?? 'no name'
241 }
242
243 return { d, name, about, picture, tags: Array.from(tags) }
244 }
245
246 export function getCommunityDefinitionFromEvent(event: Event) {
247 let name: string | undefined
248 let description: string | undefined
249 let image: string | undefined
250
251 event.tags.forEach(([tagName, tagValue]) => {
252 if (tagName === 'name') {
253 name = tagValue
254 } else if (tagName === 'description') {
255 description = tagValue
256 } else if (tagName === 'image') {
257 image = tagValue
258 }
259 })
260
261 if (!name) {
262 name = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no name'
263 }
264
265 return { name, description, image }
266 }
267
268 export function getPollMetadataFromEvent(event: Event) {
269 const options: { id: string; label: string }[] = []
270 const relayUrls: string[] = []
271 let pollType: TPollType = POLL_TYPE.SINGLE_CHOICE
272 let endsAt: number | undefined
273
274 for (const [tagName, ...tagValues] of event.tags) {
275 if (tagName === 'option' && tagValues.length >= 2) {
276 const [optionId, label] = tagValues
277 if (optionId && label) {
278 options.push({ id: optionId, label })
279 }
280 } else if (tagName === 'relay' && tagValues[0]) {
281 const normalizedUrl = normalizeUrl(tagValues[0])
282 if (normalizedUrl) relayUrls.push(tagValues[0])
283 } else if (tagName === 'polltype' && tagValues[0]) {
284 if (tagValues[0] === POLL_TYPE.MULTIPLE_CHOICE) {
285 pollType = POLL_TYPE.MULTIPLE_CHOICE
286 }
287 } else if (tagName === 'endsAt' && tagValues[0]) {
288 const timestamp = parseInt(tagValues[0])
289 if (!isNaN(timestamp)) {
290 endsAt = timestamp
291 }
292 }
293 }
294
295 if (options.length === 0) {
296 return null
297 }
298
299 return {
300 options,
301 pollType,
302 relayUrls,
303 endsAt
304 }
305 }
306
307 export function getPollResponseFromEvent(
308 event: Event,
309 optionIds: string[],
310 isMultipleChoice: boolean
311 ) {
312 const selectedOptionIds: string[] = []
313
314 for (const [tagName, ...tagValues] of event.tags) {
315 if (tagName === 'response' && tagValues[0]) {
316 if (optionIds && !optionIds.includes(tagValues[0])) {
317 continue // Skip if the response is not in the provided optionIds
318 }
319 selectedOptionIds.push(tagValues[0])
320 }
321 }
322
323 // If no valid responses are found, return null
324 if (selectedOptionIds.length === 0) {
325 return null
326 }
327
328 // If multiple responses are selected but the poll is not multiple choice, return null
329 if (selectedOptionIds.length > 1 && !isMultipleChoice) {
330 return null
331 }
332
333 return {
334 id: event.id,
335 pubkey: event.pubkey,
336 selectedOptionIds,
337 created_at: event.created_at
338 }
339 }
340
341 export function getEmojisAndEmojiSetsFromEvent(event: Event) {
342 const emojis: TEmoji[] = []
343 const emojiSetPointers: string[] = []
344
345 event.tags.forEach(([tagName, ...tagValues]) => {
346 if (tagName === 'emoji' && tagValues.length >= 2) {
347 emojis.push({
348 shortcode: tagValues[0],
349 url: tagValues[1]
350 })
351 } else if (tagName === 'a' && tagValues[0]) {
352 emojiSetPointers.push(tagValues[0])
353 }
354 })
355
356 return { emojis, emojiSetPointers }
357 }
358
359 export function getEmojiPackInfoFromEvent(event: Event) {
360 let title: string | undefined
361 const emojis: TEmoji[] = []
362
363 event.tags.forEach(([tagName, ...tagValues]) => {
364 if (tagName === 'title' && tagValues[0]) {
365 title = tagValues[0]
366 } else if (tagName === 'emoji' && tagValues.length >= 2) {
367 emojis.push({
368 shortcode: tagValues[0],
369 url: tagValues[1]
370 })
371 }
372 })
373
374 return { title, emojis }
375 }
376
377 export function getEmojisFromEvent(event: Event): TEmoji[] {
378 const info = getEmojiPackInfoFromEvent(event)
379 return info.emojis
380 }
381
382 export function getStarsFromRelayReviewEvent(event: Event): number {
383 const ratingTag = event.tags.find((t) => t[0] === 'rating')
384 if (ratingTag) {
385 const stars = parseFloat(ratingTag[1]) * 5
386 if (stars > 0 && stars <= 5) {
387 return stars
388 }
389 }
390 return 0
391 }
392
393 export function getPinnedEventHexIdSetFromPinListEvent(event?: Event | null): Set<string> {
394 return new Set(
395 event?.tags
396 .filter((tag) => tag[0] === 'e')
397 .map((tag) => tag[1])
398 .reverse()
399 .slice(0, MAX_PINNED_NOTES) ?? []
400 )
401 }
402
403 export function getFollowPackInfoFromEvent(event: Event) {
404 let title: string | undefined
405 let description: string | undefined
406 let image: string | undefined
407 const pubkeys: string[] = []
408
409 event.tags.forEach(([tagName, tagValue]) => {
410 if (tagName === 'title') {
411 title = tagValue
412 } else if (tagName === 'description') {
413 description = tagValue
414 } else if (tagName === 'image') {
415 image = tagValue
416 } else if (tagName === 'p' && Pubkey.isValidHex(tagValue)) {
417 pubkeys.push(tagValue)
418 }
419 })
420
421 if (!title) {
422 title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'Untitled Follow Pack'
423 }
424
425 return { title, description, image, pubkeys }
426 }
427