You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

879 lines
28 KiB

import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, POLL_TYPE } from '@/constants'
import { TEmoji, TMailboxRelay, TPollType, TRelayList, TRelaySet, TPaymentInfo, TProfile } from '@/types'
import { Event, kinds } from 'nostr-tools'
import { buildATag } from './draft-event'
import { getLatestEvent, getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
import { formatPubkey, pubkeyToNpub } from './pubkey'
import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import {
isKind10243HttpRelayTagUrl,
isWebsocketUrl,
normalizeAnyRelayUrl,
normalizeHttpRelayUrl,
normalizeHttpUrl,
normalizeUrl
} from './url'
import { isTorBrowser } from './utils'
import logger from '@/lib/logger'
import { buildPaytoUri } from '@/lib/payto'
import { getCanonicalPaytoType, getPaytoEditorTypeLabel } from '@/lib/payto-registry'
const emptyHttpRelayListFields = {
httpRead: [] as string[],
httpWrite: [] as string[],
httpOriginalRelays: [] as TMailboxRelay[]
}
export type GetRelayListFromEventOptions = {
/**
* When false, never substitute {@link FAST_READ_RELAY_URLS} / {@link FAST_WRITE_RELAY_URLS} for missing or
* oversized lists (use `[]` or the first 8 entries instead). Default true for anonymous / bootstrap callers.
*/
globalReadWriteFallback?: boolean
}
/**
* Merge kind-10432 (cache relays) from a network fetch with IndexedDB for session hydrate.
* Some mirrors return an empty or malformed 10432 with a newer `created_at` than good local data; prefer any
* candidate that still parses to at least one `r` WebSocket URL, then newest by time.
*/
export function mergeHydratedCacheRelayListEvents(
fetchedEvents: Event[],
stored: Event | undefined | null
): Event | null {
const fromFetch = fetchedEvents.length ? getLatestEvent(fetchedEvents) : undefined
const candidates = [fromFetch, stored ?? undefined].filter((e): e is Event => Boolean(e))
if (candidates.length === 0) return null
const relayRowCount = (e: Event) =>
getRelayListFromEvent(e, undefined, { globalReadWriteFallback: false }).originalRelays.length
const withRelays = candidates.filter((e) => relayRowCount(e) > 0)
const pool = withRelays.length > 0 ? withRelays : candidates
return pool.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))[0]!
}
export function getRelayListFromEvent(
event?: Event | null,
blockedRelays?: string[],
options?: GetRelayListFromEventOptions
) {
const globalFb = options?.globalReadWriteFallback !== false
if (!event) {
if (!globalFb) {
return {
write: [] as string[],
read: [] as string[],
originalRelays: [] as TRelayList['originalRelays'],
...emptyHttpRelayListFields
}
}
return {
write: FAST_WRITE_RELAY_URLS,
read: FAST_READ_RELAY_URLS,
originalRelays: [],
...emptyHttpRelayListFields
}
}
const torBrowserDetected = isTorBrowser()
const relayList = { write: [], read: [], originalRelays: [] } as Pick<TRelayList, 'write' | 'read' | 'originalRelays'>
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
// Filter out empty, invalid, or malformed URLs
if (!url || typeof url !== 'string' || url.trim() === '' || url === 'ws://' || url === 'wss://') return
if (!isWebsocketUrl(url)) return
const normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) return
if (isRelayBlockedByUser(normalizedUrl, blockedRelays)) return
const scope = type === 'read' ? 'read' : type === 'write' ? 'write' : 'both'
relayList.originalRelays.push({ url: normalizedUrl, scope })
// Filter out .onion URLs if not using Tor browser
if (normalizedUrl.endsWith('.onion/') && !torBrowserDetected) return
if (type === 'write') {
relayList.write.push(normalizedUrl)
} else if (type === 'read') {
relayList.read.push(normalizedUrl)
} else {
relayList.write.push(normalizedUrl)
relayList.read.push(normalizedUrl)
}
})
// If there are too many relays, use the default inbox/outbox relays.
// Because they don't know anything about relays, their settings cannot be trusted
const readOut =
relayList.read.length && relayList.read.length <= 8
? relayList.read
: globalFb
? FAST_READ_RELAY_URLS
: relayList.read.slice(0, 8)
const writeOut =
relayList.write.length && relayList.write.length <= 8
? relayList.write
: globalFb
? FAST_WRITE_RELAY_URLS
: relayList.write.slice(0, 8)
return {
write: writeOut,
read: readOut,
originalRelays: relayList.originalRelays,
...emptyHttpRelayListFields
}
}
/**
* Read-side `r` tags from a relay list event (e.g. kind 10012) without {@link FAST_READ_RELAY_URLS} fallback
* when the list is empty or oversized — for strict viewer-owned REQ stacks.
*/
export function getRelayListReadFromEventNoFastFallback(
event: Event | null | undefined,
blockedRelays?: string[]
): string[] {
if (!event) return []
const torBrowserDetected = isTorBrowser()
const read: string[] = []
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
if (!url || typeof url !== 'string' || url.trim() === '' || url === 'ws://' || url === 'wss://') return
if (!isWebsocketUrl(url)) return
const normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) return
if (isRelayBlockedByUser(normalizedUrl, blockedRelays)) return
if (normalizedUrl.endsWith('.onion/') && !torBrowserDetected) return
if (type === 'write') return
if (type === 'read') {
read.push(normalizedUrl)
} else {
read.push(normalizedUrl)
}
})
if (read.length === 0) return []
if (read.length <= 8) return read
return read.slice(0, 8)
}
/** Kind 10243: `r` tags with http(s) URLs only; same read/write/both semantics as NIP-65. */
export function getHttpRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) {
const out = {
httpRead: [] as string[],
httpWrite: [] as string[],
httpOriginalRelays: [] as TMailboxRelay[]
}
if (!event) return out
const torBrowserDetected = isTorBrowser()
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
if (!url || typeof url !== 'string' || url.trim() === '') return
if (!isKind10243HttpRelayTagUrl(url)) return
const normalizedUrl = normalizeHttpRelayUrl(url)
if (!normalizedUrl) return
if (isRelayBlockedByUser(normalizedUrl, blockedRelays)) return
const scope = type === 'read' ? 'read' : type === 'write' ? 'write' : 'both'
out.httpOriginalRelays.push({ url: normalizedUrl, scope })
if ((normalizedUrl.includes('.onion') || normalizedUrl.endsWith('.onion/')) && !torBrowserDetected) return
if (type === 'write') {
out.httpWrite.push(normalizedUrl)
} else if (type === 'read') {
out.httpRead.push(normalizedUrl)
} else {
out.httpWrite.push(normalizedUrl)
out.httpRead.push(normalizedUrl)
}
})
return {
httpRead: Array.from(new Set(out.httpRead)),
httpWrite: Array.from(new Set(out.httpWrite)),
httpOriginalRelays: out.httpOriginalRelays
}
}
/** Kind 0 JSON `nip05` may be a string or string[]; tags are always strings. */
function firstNip05StringFromJson(raw: unknown): string | undefined {
if (typeof raw === 'string') {
const t = raw.trim()
return t || undefined
}
if (Array.isArray(raw)) {
for (const x of raw) {
if (typeof x === 'string') {
const t = x.trim()
if (t) return t
}
}
}
return undefined
}
function nip05ListFromJson(raw: unknown): string[] | undefined {
const out: string[] = []
const seen = new Set<string>()
const add = (s: string) => {
const t = s.trim()
if (!t || seen.has(t)) return
seen.add(t)
out.push(t)
}
if (typeof raw === 'string') add(raw)
else if (Array.isArray(raw)) {
for (const x of raw) {
if (typeof x === 'string') add(x)
}
}
return out.length > 0 ? out : undefined
}
/**
* Kind-0 metadata: profile is marked as a bot when there is `["bot"]` or `["bot","true"]`
* (case-insensitive tag name and value) and no `["bot","false"]` tag.
*/
export function profileIsBotFromKind0Tags(tags: string[][]): boolean {
let hasFalse = false
let hasAffirmative = false
for (const raw of tags) {
if (!Array.isArray(raw) || !raw.length) continue
if (String(raw[0]).toLowerCase() !== 'bot') continue
if (raw.length === 1) {
hasAffirmative = true
continue
}
const v = String(raw[1] ?? '').toLowerCase()
if (v === 'false') hasFalse = true
else if (v === 'true') hasAffirmative = true
}
if (hasFalse) return false
return hasAffirmative
}
export function getProfileFromEvent(event: Event) {
// Parse JSON content as fallback
let profileObj: any = {}
try {
profileObj = JSON.parse(event.content || '{}')
} catch (err) {
logger.error('Failed to parse event metadata JSON', { error: err, content: event.content })
}
// Extract values from tags (preferred over JSON content)
const nip05Tags = event.tags.filter(tag => tag[0] === 'nip05' && tag[1]).map(tag => tag[1])
const websiteTags = event.tags.filter(tag => tag[0] === 'website' && tag[1]).map(tag => tag[1])
const lud06Tags = event.tags.filter(tag => tag[0] === 'lud06' && tag[1]).map(tag => tag[1])
const lud16Tags = event.tags.filter(tag => tag[0] === 'lud16' && tag[1]).map(tag => tag[1])
/** `["w", currency, address, network]` — multi-wallet hints on kind 0 */
const wWalletTags = event.tags
.filter((tag): tag is string[] => tag[0] === 'w' && !!tag[1] && !!tag[2] && !!tag[3])
.map((tag) => ({
currency: String(tag[1]).trim(),
address: String(tag[2]).trim(),
network: String(tag[3]).trim().toLowerCase()
}))
.filter((w) => w.address && w.network)
const wLightningAddresses = wWalletTags.filter((w) => w.network === 'lightning').map((w) => w.address)
// Use first tag entry for single values, or fallback to JSON
const nip05 =
nip05Tags.length > 0 ? nip05Tags[0] : firstNip05StringFromJson(profileObj.nip05)
const nip05List =
nip05Tags.length > 0 ? nip05Tags : nip05ListFromJson(profileObj.nip05)
const website = websiteTags.length > 0
? normalizeHttpUrl(websiteTags[0])
: (profileObj.website ? normalizeHttpUrl(profileObj.website) : undefined)
const websiteList = websiteTags.length > 0
? websiteTags.map(w => normalizeHttpUrl(w))
: (profileObj.website ? [normalizeHttpUrl(profileObj.website)] : undefined)
// Use FIRST lightning tag from kind 0 only (for zap button - do not use subsequent tags or kind 10133)
const lud06 = lud06Tags.length > 0 ? lud06Tags[0] : profileObj.lud06
const lud16 = lud16Tags.length > 0 ? lud16Tags[0] : profileObj.lud16
// Build lightning address from FIRST tag or JSON (prefer first tag, fallback to JSON)
// This is used by the zap button and should only come from kind 0, not kind 10133 payto
const lightningAddressFromTags = lud16 || lud06 || wLightningAddresses[0]
const lightningAddressFromJson = getLightningAddressFromProfile({ lud06: profileObj.lud06, lud16: profileObj.lud16 } as TProfile)
const lightningAddress = lightningAddressFromTags || lightningAddressFromJson
// Build list of all lightning addresses (from tags first, then JSON)
const lightningAddressList = [...new Set([
...(lud16Tags.length > 0 ? lud16Tags : []),
...(lud06Tags.length > 0 ? lud06Tags : []),
...wLightningAddresses,
...(profileObj.lud16 ? [profileObj.lud16] : []),
...(profileObj.lud06 ? [profileObj.lud06] : []),
...(lightningAddressFromJson && !lightningAddressFromTags ? [lightningAddressFromJson] : [])
])].filter(Boolean)
const username =
profileObj.display_name?.trim() ||
profileObj.name?.trim() ||
nip05?.split('@')[0]?.trim()
// Resolve picture URL (prefer tag over JSON)
const pictureTags = event.tags.filter(tag => tag[0] === 'picture' && tag[1]).map(tag => tag[1])
const avatarUrl = pictureTags.length > 0 ? pictureTags[0] : profileObj.picture
// Look up file size from any matching imeta tag in the kind-0 event
let pictureSize: number | undefined
if (avatarUrl) {
for (const tag of event.tags) {
const info = getImetaInfoFromImetaTag(tag)
if (info && info.url === avatarUrl && info.size != null) {
pictureSize = info.size
break
}
}
}
return {
pubkey: event.pubkey,
npub: pubkeyToNpub(event.pubkey) ?? '',
banner: profileObj.banner,
avatar: avatarUrl,
pictureSize,
isBot: event.kind === 0 ? profileIsBotFromKind0Tags(event.tags as string[][]) : undefined,
username: username || formatPubkey(event.pubkey),
original_username: username,
nip05,
nip05List: nip05List && nip05List.length > 0 ? nip05List : undefined,
about: profileObj.about,
website,
websiteList: websiteList && websiteList.length > 0 ? websiteList : undefined,
lud06,
lud16,
lightningAddress,
lightningAddressList: lightningAddressList.length > 0 ? lightningAddressList : undefined,
wWalletTags: wWalletTags.length > 0 ? wWalletTags : undefined,
created_at: event.created_at
}
}
export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null {
if (event.kind !== 10133) return null
// Parse JSON content as fallback
let paymentInfo: any = {}
try {
if (event.content) {
paymentInfo = JSON.parse(event.content)
}
} catch (err) {
logger.error('Failed to parse payment info JSON', { error: err, content: event.content })
}
// Extract payment methods from tags (preferred over JSON content)
// NIP-A3 format: ["payto", "<type>", "<authority>", "<optional_extra_1>", ...]
// tag[0] = "payto", tag[1] = type, tag[2] = authority
const paytoTags = event.tags.filter(tag => tag[0] === 'payto' && tag[1] && tag[2])
// Build methods array from tags
const methods: TPaymentInfo['methods'] = []
// Parse each payto tag according to NIP-A3 spec
paytoTags.forEach((tag) => {
const type = getCanonicalPaytoType(tag[1]?.toLowerCase() || 'lightning')
const authority = tag[2] || ''
const extra = tag.slice(3) // Optional extra fields
const paytoUri = buildPaytoUri(type, authority)
const method: any = {
type,
authority,
payto: paytoUri,
displayType: getPaytoEditorTypeLabel(type),
...(extra.length > 0 && { extra })
}
methods.push(method)
})
// If we have methods in JSON but no tags, use JSON methods
if (methods.length === 0 && paymentInfo.methods && Array.isArray(paymentInfo.methods)) {
methods.push(
...paymentInfo.methods.map((m: any) => {
const type = getCanonicalPaytoType((m.type || 'lightning').toLowerCase())
const authority = m.authority || m.address || ''
return {
...m,
type,
authority,
displayType: m.displayType || getPaytoEditorTypeLabel(type),
payto: m.payto || (type && authority ? `payto://${type}/${authority}` : undefined)
}
})
)
}
// If we have payto at root level in JSON but no methods array
if (methods.length === 0 && paymentInfo.payto) {
methods.push({
payto: paymentInfo.payto,
type: paymentInfo.type || 'lightning',
authority: paymentInfo.authority,
displayType: getPaytoEditorTypeLabel(paymentInfo.type || 'lightning')
})
}
// Build result
const result: TPaymentInfo = {
...paymentInfo,
methods: methods.length > 0 ? methods : undefined
}
logger.debug('Parsed payment info', {
hasMethods: !!result.methods,
methodsCount: result.methods?.length || 0,
paytoTagsCount: paytoTags.length,
content: event.content?.substring(0, 200)
})
return result
}
export function getRelaySetFromEvent(event: Event, blockedRelays?: string[]): TRelaySet {
const id = getReplaceableEventIdentifier(event)
const relayUrls = event.tags
.filter(tagNameEquals('relay'))
.map((tag) => tag[1])
.filter((url) => url && isWebsocketUrl(url))
.map((url) => normalizeUrl(url))
.filter((url): url is string => !!url && !isRelayBlockedByUser(url, blockedRelays))
let name = event.tags.find(tagNameEquals('title'))?.[1]
if (!name) {
name = id
}
return { id, name, relayUrls, aTag: buildATag(event) }
}
export function getZapInfoFromEvent(receiptEvent: Event) {
if (
receiptEvent.kind !== kinds.Zap &&
receiptEvent.kind !== ExtendedKind.ZAP_RECEIPT &&
receiptEvent.kind !== ExtendedKind.ZAP_REQUEST
) {
return null
}
// Kind 9734 — zap request: all data is directly on the event (no bolt11, no description wrapper).
if (receiptEvent.kind === ExtendedKind.ZAP_REQUEST) {
const senderPubkey = receiptEvent.pubkey
let recipientPubkey: string | undefined
let originalEventId: string | undefined
let eventId: string | undefined
let amount: number | undefined
const comment = receiptEvent.content || undefined
try {
receiptEvent.tags.forEach((tag) => {
const [tagName, tagValue] = tag
switch (tagName) {
case 'p':
recipientPubkey = tagValue
break
case 'e':
case 'E':
originalEventId = tag[1]
eventId = generateBech32IdFromETag(tag)
break
case 'a':
originalEventId = tag[1]
eventId = generateBech32IdFromATag(tag)
break
case 'amount':
if (tagValue) amount = Math.floor(parseInt(tagValue, 10) / 1000)
break
}
})
if (!recipientPubkey || !amount) return null
return { senderPubkey, recipientPubkey, eventId, originalEventId, invoice: undefined, amount, comment, preimage: undefined }
} catch {
return null
}
}
let senderPubkey: string | undefined
let recipientPubkey: string | undefined
let originalEventId: string | undefined
let eventId: string | undefined
let invoice: string | undefined
let amount: number | undefined
let comment: string | undefined
let description: string | undefined
let preimage: string | undefined
try {
receiptEvent.tags.forEach((tag) => {
const [tagName, tagValue] = tag
switch (tagName) {
case 'P':
senderPubkey = tagValue
break
case 'p':
recipientPubkey = tagValue
break
case 'e':
case 'E':
originalEventId = tag[1]
eventId = generateBech32IdFromETag(tag)
break
case 'a':
originalEventId = tag[1]
eventId = generateBech32IdFromATag(tag)
break
case 'bolt11':
invoice = tagValue
break
case 'description':
description = tagValue
break
case 'preimage':
preimage = tagValue
break
}
})
if (!recipientPubkey || !invoice) return null
// Try to parse amount from invoice, fallback to description if invoice is invalid
try {
amount = getAmountFromInvoice(invoice)
} catch {
amount = 0
}
if (description) {
try {
const zapRequest = JSON.parse(description)
comment = zapRequest.content
if (!senderPubkey) {
senderPubkey = zapRequest.pubkey
}
// Extract recipient from zap request
// Priority: e tag (event) -> a tag (addressable event) -> p tag (profile)
if (zapRequest.tags) {
const eTag = zapRequest.tags.find((tag: string[]) => tag[0] === 'e')
const aTag = zapRequest.tags.find((tag: string[]) => tag[0] === 'a')
const pTag = zapRequest.tags.find((tag: string[]) => tag[0] === 'p')
if (eTag && eTag[1]) {
// Event zap - recipient is the author of the zapped event
// We'll need to fetch this event to get the author's pubkey
// For now, fall back to p tag
if (pTag && pTag[1]) {
recipientPubkey = pTag[1]
}
} else if (aTag && aTag[1]) {
// Addressable event zap - recipient is the author of the zapped event
// We'll need to fetch this event to get the author's pubkey
// For now, fall back to p tag
if (pTag && pTag[1]) {
recipientPubkey = pTag[1]
}
} else if (pTag && pTag[1]) {
// Profile zap - recipient is directly specified
recipientPubkey = pTag[1]
}
}
// If invoice parsing failed, try to get amount from zap request tags
if (amount === 0 && zapRequest.tags) {
const amountTag = zapRequest.tags.find((tag: string[]) => tag[0] === 'amount')
if (amountTag && amountTag[1]) {
const millisats = parseInt(amountTag[1])
amount = millisats / 1000 // Convert millisats to sats
}
}
} catch {
// ignore
}
}
return {
senderPubkey,
recipientPubkey,
eventId,
originalEventId,
invoice,
amount,
comment,
preimage
}
} catch {
return null
}
}
// Helper function to convert d-tag to title case
export function dTagToTitleCase(dTag: string): string {
return dTag
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
export function getLongFormArticleMetadataFromEvent(event: Event) {
let title: string | undefined
let summary: string | undefined
let image: string | undefined
const tags = new Set<string>()
event.tags.forEach(([tagName, tagValue]) => {
const n = tagName?.toLowerCase()
if (n === 'title' && tagValue?.trim()) {
title = tagValue.trim()
} else if (n === 'summary' && tagValue?.trim()) {
summary = tagValue.trim()
} else if (n === 'image' && tagValue?.trim()) {
image = tagValue.trim()
} else if (n === 't' && tagValue?.trim() && tags.size < 6) {
tags.add(tagValue.trim().replace(/^#/, '').toLowerCase())
}
})
if (!title) {
const dTag = event.tags.find(tagNameEquals('d'))?.[1]
if (dTag) {
title = dTagToTitleCase(dTag)
}
}
return { title, summary, image, tags: Array.from(tags) }
}
export function getLiveEventMetadataFromEvent(event: Event) {
let title: string | undefined
let room: string | undefined
let summary: string | undefined
let image: string | undefined
let thumb: string | undefined
let status: string | undefined
const tags = new Set<string>()
event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'title' && tagValue?.trim()) {
title = tagValue.trim()
} else if (tagName === 'room' && tagValue?.trim()) {
room = tagValue.trim()
} else if (tagName === 'summary' && tagValue?.trim()) {
summary = tagValue.trim()
} else if (tagName === 'image' && tagValue?.trim()) {
image = tagValue.trim()
} else if (tagName === 'thumb' && tagValue?.trim()) {
thumb = tagValue.trim()
} else if (tagName === 'status' && tagValue?.trim()) {
status = tagValue.trim().toLowerCase()
} else if (tagName === 't' && tagValue?.trim() && tags.size < 6) {
tags.add(tagValue.trim().toLowerCase())
}
})
const dTag = event.tags.find(tagNameEquals('d'))?.[1]
const dTitle = dTag ? dTagToTitleCase(dTag) : undefined
/** NIP-53 meeting space (30312) uses `room`; live ticker / meeting (30311/30313) use `title` first. */
if (event.kind === 30312) {
title = room || title || dTitle || 'no title'
} else {
title = title || room || dTitle || 'no title'
}
return { title, summary, image, thumb, status, tags: Array.from(tags) }
}
export function getGroupMetadataFromEvent(event: Event) {
let d: string | undefined
let name: string | undefined
let about: string | undefined
let picture: string | undefined
const tags = new Set<string>()
event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'name') {
name = tagValue
} else if (tagName === 'about') {
about = tagValue
} else if (tagName === 'picture') {
picture = tagValue
} else if (tagName === 't' && tagValue) {
tags.add(tagValue.toLowerCase())
} else if (tagName === 'd') {
d = tagValue
}
})
if (!name) {
name = d ?? 'no name'
}
return { d, name, about, picture, tags: Array.from(tags) }
}
export function getCommunityDefinitionFromEvent(event: Event) {
let name: string | undefined
let description: string | undefined
let image: string | undefined
event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'name') {
name = tagValue
} else if (tagName === 'description') {
description = tagValue
} else if (tagName === 'image') {
image = tagValue
}
})
if (!name) {
name = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no name'
}
return { name, description, image }
}
export function getPollMetadataFromEvent(event: Event) {
const options: { id: string; label: string }[] = []
const relayUrls: string[] = []
let pollType: TPollType = POLL_TYPE.SINGLE_CHOICE
let endsAt: number | undefined
for (const [tagName, ...tagValues] of event.tags) {
if (tagName === 'option' && tagValues.length >= 2) {
const [optionId, label] = tagValues
if (optionId && label) {
options.push({ id: optionId, label })
}
} else if (tagName === 'relay' && tagValues[0]) {
const normalizedUrl = normalizeUrl(tagValues[0])
if (normalizedUrl) relayUrls.push(tagValues[0])
} else if (tagName === 'polltype' && tagValues[0]) {
if (tagValues[0] === POLL_TYPE.MULTIPLE_CHOICE) {
pollType = POLL_TYPE.MULTIPLE_CHOICE
}
} else if (tagName === 'endsAt' && tagValues[0]) {
const timestamp = parseInt(tagValues[0])
if (!isNaN(timestamp)) {
endsAt = timestamp
}
}
}
if (options.length === 0) {
return null
}
return {
options,
pollType,
relayUrls,
endsAt
}
}
export function getPollResponseFromEvent(
event: Event,
optionIds: string[],
isMultipleChoice: boolean
) {
const selectedOptionIds: string[] = []
for (const [tagName, ...tagValues] of event.tags) {
if (tagName === 'response' && tagValues[0]) {
if (optionIds && !optionIds.includes(tagValues[0])) {
continue // Skip if the response is not in the provided optionIds
}
selectedOptionIds.push(tagValues[0])
}
}
// If no valid responses are found, return null
if (selectedOptionIds.length === 0) {
return null
}
// If multiple responses are selected but the poll is not multiple choice, return null
if (selectedOptionIds.length > 1 && !isMultipleChoice) {
return null
}
return {
id: event.id,
pubkey: event.pubkey,
selectedOptionIds,
created_at: event.created_at
}
}
export function getEmojisAndEmojiSetsFromEvent(event: Event) {
const emojis: TEmoji[] = []
const emojiSetPointers: string[] = []
event.tags.forEach(([tagName, ...tagValues]) => {
if (tagName === 'emoji' && tagValues.length >= 2) {
emojis.push({
shortcode: tagValues[0],
url: tagValues[1]
})
} else if (tagName === 'a' && tagValues[0]) {
const coord = tagValues[0]
const kindStr = coord.split(':')[0]
const kind = parseInt(kindStr ?? '', 10)
if (kind === kinds.Emojisets) {
emojiSetPointers.push(tagValues[0])
}
}
})
return { emojis, emojiSetPointers }
}
export function getEmojisFromEvent(event: Event): TEmoji[] {
const emojis: TEmoji[] = []
event.tags.forEach(([tagName, ...tagValues]) => {
if (tagName === 'emoji' && tagValues.length >= 2) {
emojis.push({
shortcode: tagValues[0],
url: tagValues[1]
})
}
})
return emojis
}
export function getStarsFromRelayReviewEvent(event: Event): number {
const ratingTag = event.tags.find((t) => t[0] === 'rating')
if (!ratingTag?.[1]?.trim()) return 0
const raw = parseFloat(ratingTag[1])
if (Number.isNaN(raw) || raw <= 0) return 0
// This app publishes `rating` as stars/5 (e.g. 5★ → "1"); scale back to 1–5.
if (raw <= 1) {
const scaled = raw * 5
if (scaled > 0 && scaled <= 5) return scaled
return 0
}
// Many clients use a plain 1–5 value in the tag.
if (raw >= 1 && raw <= 5) return raw
return 0
}
/** Relay URL from the `d` tag (NIP for relay reviews). */
export function getRelayUrlFromRelayReviewEvent(event: Event): string | undefined {
const d = event.tags.find((t) => t[0] === 'd')?.[1]?.trim()
if (!d) return undefined
return normalizeAnyRelayUrl(d) || d
}