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