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.
763 lines
25 KiB
763 lines
25 KiB
import { ExtendedKind, FAST_READ_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 { getReplaceableEventIdentifier } from './event' |
|
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' |
|
import { formatPubkey, pubkeyToNpub } from './pubkey' |
|
import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag' |
|
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeHttpUrl, normalizeUrl } from './url' |
|
import { isTorBrowser } from './utils' |
|
import logger from '@/lib/logger' |
|
|
|
const emptyHttpRelayListFields = { |
|
httpRead: [] as string[], |
|
httpWrite: [] as string[], |
|
httpOriginalRelays: [] as TMailboxRelay[] |
|
} |
|
|
|
export function getRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) { |
|
if (!event) { |
|
return { |
|
write: FAST_READ_RELAY_URLS, |
|
read: FAST_READ_RELAY_URLS, |
|
originalRelays: [], |
|
...emptyHttpRelayListFields |
|
} |
|
} |
|
|
|
const torBrowserDetected = isTorBrowser() |
|
const relayList = { write: [], read: [], originalRelays: [] } as Pick<TRelayList, 'write' | 'read' | 'originalRelays'> |
|
|
|
// Normalize blocked relays for comparison |
|
const normalizedBlockedRelays = (blockedRelays || []).map(url => normalizeUrl(url) || url) |
|
|
|
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 |
|
|
|
// Filter out blocked relays |
|
if (normalizedBlockedRelays.includes(normalizedUrl)) 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 FAST_READ_RELAY_URLS |
|
// Because they don't know anything about relays, their settings cannot be trusted |
|
return { |
|
write: relayList.write.length && relayList.write.length <= 8 ? relayList.write : FAST_READ_RELAY_URLS, |
|
read: relayList.read.length && relayList.write.length <= 8 ? relayList.read : FAST_READ_RELAY_URLS, |
|
originalRelays: relayList.originalRelays, |
|
...emptyHttpRelayListFields |
|
} |
|
} |
|
|
|
/** 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() |
|
const normalizedBlockedRelays = (blockedRelays || []).map((url) => normalizeUrl(url) || url) |
|
|
|
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => { |
|
if (!url || typeof url !== 'string' || url.trim() === '') return |
|
if (!isHttpRelayUrl(url)) return |
|
|
|
const normalizedUrl = normalizeHttpRelayUrl(url) |
|
if (!normalizedUrl) return |
|
|
|
const asWs = normalizeUrl(url) |
|
if (asWs && normalizedBlockedRelays.includes(asWs)) return |
|
if (normalizedBlockedRelays.includes(normalizedUrl)) 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 |
|
} |
|
|
|
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]) |
|
|
|
// 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 |
|
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 : []), |
|
...(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, |
|
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, |
|
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 = tag[1]?.toLowerCase() || 'lightning' // Normalize to lowercase per spec |
|
const authority = tag[2] || '' |
|
const extra = tag.slice(3) // Optional extra fields |
|
|
|
// Build payto URI: payto://<type>/<authority> |
|
const paytoUri = `payto://${type}/${authority}` |
|
|
|
const method: any = { |
|
type, |
|
authority, |
|
payto: paytoUri, |
|
// Map common types to display names |
|
displayType: type === 'lightning' ? 'Lightning Network' : |
|
type === 'bitcoin' ? 'Bitcoin' : |
|
type === 'ethereum' ? 'Ethereum' : |
|
type === 'monero' ? 'Monero' : |
|
type === 'nano' ? 'Nano' : |
|
type === 'cashme' ? 'Cash App' : |
|
type === 'revolut' ? 'Revolut' : |
|
type === 'venmo' ? 'Venmo' : |
|
type.charAt(0).toUpperCase() + type.slice(1), |
|
...(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) => ({ |
|
...m, |
|
payto: m.payto || (m.type && m.authority ? `payto://${m.type}/${m.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: paymentInfo.type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment' |
|
}) |
|
} |
|
|
|
// 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) |
|
|
|
// Normalize blocked relays for comparison |
|
const normalizedBlockedRelays = (blockedRelays || []).map(url => normalizeUrl(url) || url) |
|
|
|
const relayUrls = event.tags |
|
.filter(tagNameEquals('relay')) |
|
.map((tag) => tag[1]) |
|
.filter((url) => url && isWebsocketUrl(url)) |
|
.map((url) => normalizeUrl(url)) |
|
.filter((url) => !normalizedBlockedRelays.includes(url)) // Filter out blocked relays |
|
|
|
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_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 |
|
} |
|
} |
|
|
|
/** |
|
* Kind 9735: include in timelines and reply lists only when amount (sats) is known and at least `thresholdSats`. |
|
* Matches {@link NoteList} zap filtering. |
|
*/ |
|
export function shouldIncludeZapReceiptAtReplyThreshold(receipt: Event, thresholdSats: number): boolean { |
|
if (receipt.kind !== kinds.Zap) return true |
|
const zapInfo = getZapInfoFromEvent(receipt) |
|
if (!zapInfo || zapInfo.amount === undefined || zapInfo.amount === 0 || zapInfo.amount < thresholdSats) { |
|
return false |
|
} |
|
return true |
|
} |
|
|
|
// 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]) => { |
|
if (tagName === 'title') { |
|
title = tagValue |
|
} else if (tagName === 'summary') { |
|
summary = tagValue |
|
} else if (tagName === 'image') { |
|
image = tagValue |
|
} else if (tagName === 't' && tagValue && tags.size < 6) { |
|
tags.add(tagValue.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') { |
|
title = tagValue |
|
} else if (tagName === 'room' && tagValue?.trim()) { |
|
room = tagValue.trim() |
|
} else if (tagName === 'summary') { |
|
summary = tagValue |
|
} 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 && tags.size < 6) { |
|
tags.add(tagValue.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 |
|
}
|
|
|