draft-event.ts raw

   1  import { ApplicationDataKey, EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } from '@/constants'
   2  import client from '@/services/client.service'
   3  import customEmojiService from '@/services/custom-emoji.service'
   4  import mediaUpload from '@/services/media-upload.service'
   5  import {
   6    TDMDeletedState,
   7    TDraftEvent,
   8    TEmoji,
   9    TMailboxRelay,
  10    TMailboxRelayScope,
  11    TPollCreateData,
  12    TRelaySet
  13  } from '@/types'
  14  import { sha256 } from '@noble/hashes/sha2'
  15  import dayjs from 'dayjs'
  16  import { Event, kinds, nip19 } from 'nostr-tools'
  17  import {
  18    getReplaceableCoordinate,
  19    getReplaceableCoordinateFromEvent,
  20    getRootTag,
  21    isProtectedEvent,
  22    isReplaceableEvent
  23  } from './event'
  24  import { determineExternalContentKind } from './external-content'
  25  import { randomString } from './random'
  26  import { generateBech32IdFromETag, tagNameEquals } from './tag'
  27  
  28  const draftEventCache: Map<string, string> = new Map()
  29  
  30  export function deleteDraftEventCache(draftEvent: TDraftEvent) {
  31    const key = generateDraftEventCacheKey(draftEvent)
  32    draftEventCache.delete(key)
  33  }
  34  
  35  function setDraftEventCache(baseDraft: Omit<TDraftEvent, 'created_at'>): TDraftEvent {
  36    const cacheKey = generateDraftEventCacheKey(baseDraft)
  37    const cache = draftEventCache.get(cacheKey)
  38    if (cache) {
  39      return JSON.parse(cache)
  40    }
  41    const draftEvent = { ...baseDraft, created_at: dayjs().unix() }
  42    draftEventCache.set(cacheKey, JSON.stringify(draftEvent))
  43  
  44    return draftEvent
  45  }
  46  
  47  function generateDraftEventCacheKey(draft: Omit<TDraftEvent, 'created_at'>) {
  48    const str = JSON.stringify({
  49      content: draft.content,
  50      kind: draft.kind,
  51      tags: draft.tags
  52    })
  53  
  54    const encoder = new TextEncoder()
  55    const data = encoder.encode(str)
  56    const hashBuffer = sha256(data)
  57    const hashArray = Array.from(new Uint8Array(hashBuffer))
  58    return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
  59  }
  60  
  61  // https://github.com/nostr-protocol/nips/blob/master/25.md
  62  export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent {
  63    const tags: string[][] = []
  64    tags.push(buildETag(event.id, event.pubkey))
  65    tags.push(buildPTag(event.pubkey))
  66    if (event.kind !== kinds.ShortTextNote) {
  67      tags.push(buildKTag(event.kind))
  68    }
  69  
  70    if (isReplaceableEvent(event.kind)) {
  71      tags.push(buildATag(event))
  72    }
  73  
  74    let content: string
  75    if (typeof emoji === 'string') {
  76      content = emoji
  77    } else {
  78      content = `:${emoji.shortcode}:`
  79      tags.push(buildEmojiTag(emoji))
  80    }
  81  
  82    return {
  83      kind: kinds.Reaction,
  84      content,
  85      tags,
  86      created_at: dayjs().unix()
  87    }
  88  }
  89  
  90  export function createExternalContentReactionDraftEvent(
  91    externalContent: string,
  92    emoji: TEmoji | string = '+'
  93  ): TDraftEvent {
  94    const tags: string[][] = []
  95    tags.push(buildITag(externalContent))
  96    const kind = determineExternalContentKind(externalContent)
  97    if (kind) {
  98      tags.push(buildKTag(kind))
  99    }
 100  
 101    let content: string
 102    if (typeof emoji === 'string') {
 103      content = emoji
 104    } else {
 105      content = `:${emoji.shortcode}:`
 106      tags.push(buildEmojiTag(emoji))
 107    }
 108  
 109    return {
 110      kind: ExtendedKind.EXTERNAL_CONTENT_REACTION,
 111      content,
 112      tags,
 113      created_at: dayjs().unix()
 114    }
 115  }
 116  
 117  // https://github.com/nostr-protocol/nips/blob/master/18.md
 118  export function createRepostDraftEvent(event: Event): TDraftEvent {
 119    const isProtected = isProtectedEvent(event)
 120    const tags = [buildETag(event.id, event.pubkey), buildPTag(event.pubkey)]
 121  
 122    if (event.kind === kinds.ShortTextNote) {
 123      return {
 124        kind: kinds.Repost,
 125        content: isProtected ? '' : JSON.stringify(event),
 126        tags,
 127        created_at: dayjs().unix()
 128      }
 129    }
 130  
 131    tags.push(buildKTag(event.kind))
 132  
 133    const isReplaceable = isReplaceableEvent(event.kind)
 134    if (isReplaceable) {
 135      tags.push(buildATag(event))
 136    }
 137  
 138    return {
 139      kind: kinds.GenericRepost,
 140      content: isProtected || isReplaceable ? '' : JSON.stringify(event),
 141      tags,
 142      created_at: dayjs().unix()
 143    }
 144  }
 145  
 146  export async function createShortTextNoteDraftEvent(
 147    content: string,
 148    mentions: string[],
 149    options: {
 150      parentEvent?: Event
 151      addClientTag?: boolean
 152      protectedEvent?: boolean
 153      isNsfw?: boolean
 154    } = {}
 155  ): Promise<TDraftEvent> {
 156    const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
 157    const { quoteTags, rootTag, parentTag } = await extractRelatedEventIds(
 158      transformedEmojisContent,
 159      options.parentEvent
 160    )
 161    const hashtags = extractHashtags(transformedEmojisContent)
 162  
 163    const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
 164  
 165    // imeta tags
 166    const images = extractImagesFromContent(transformedEmojisContent)
 167    if (images && images.length) {
 168      tags.push(...generateImetaTags(images))
 169    }
 170  
 171    // q tags
 172    tags.push(...quoteTags)
 173  
 174    // thread tags
 175    if (rootTag) {
 176      tags.push(rootTag)
 177    }
 178  
 179    if (parentTag) {
 180      tags.push(parentTag)
 181    }
 182  
 183    // p tags
 184    tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
 185  
 186    if (options.addClientTag) {
 187      tags.push(buildClientTag())
 188    }
 189  
 190    if (options.isNsfw) {
 191      tags.push(buildNsfwTag())
 192    }
 193  
 194    if (options.protectedEvent) {
 195      tags.push(buildProtectedTag())
 196    }
 197  
 198    const baseDraft = {
 199      kind: kinds.ShortTextNote,
 200      content: transformedEmojisContent,
 201      tags
 202    }
 203    return setDraftEventCache(baseDraft)
 204  }
 205  
 206  // https://github.com/nostr-protocol/nips/blob/master/51.md
 207  export function createRelaySetDraftEvent(relaySet: Omit<TRelaySet, 'aTag'>): TDraftEvent {
 208    return {
 209      kind: kinds.Relaysets,
 210      content: '',
 211      tags: [
 212        buildDTag(relaySet.id),
 213        buildTitleTag(relaySet.name),
 214        ...relaySet.relayUrls.map((url) => buildRelayTag(url))
 215      ],
 216      created_at: dayjs().unix()
 217    }
 218  }
 219  
 220  export async function createCommentDraftEvent(
 221    content: string,
 222    parentStuff: Event | string,
 223    mentions: string[],
 224    options: {
 225      addClientTag?: boolean
 226      protectedEvent?: boolean
 227      isNsfw?: boolean
 228    } = {}
 229  ): Promise<TDraftEvent> {
 230    const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
 231    const {
 232      quoteTags,
 233      rootEventId,
 234      rootCoordinateTag,
 235      rootKind,
 236      rootPubkey,
 237      rootUrl,
 238      parentEvent,
 239      externalContent
 240    } = await extractCommentMentions(transformedEmojisContent, parentStuff)
 241    const hashtags = extractHashtags(transformedEmojisContent)
 242  
 243    const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag))).concat(quoteTags)
 244  
 245    const images = extractImagesFromContent(transformedEmojisContent)
 246    if (images && images.length) {
 247      tags.push(...generateImetaTags(images))
 248    }
 249  
 250    tags.push(
 251      ...mentions
 252        .filter((pubkey) => pubkey !== parentEvent?.pubkey)
 253        .map((pubkey) => buildPTag(pubkey))
 254    )
 255  
 256    if (rootCoordinateTag) {
 257      tags.push(rootCoordinateTag)
 258    } else if (rootEventId) {
 259      tags.push(buildETag(rootEventId, rootPubkey, '', true))
 260    }
 261    if (rootPubkey) {
 262      tags.push(buildPTag(rootPubkey, true))
 263    }
 264    if (rootKind) {
 265      tags.push(buildKTag(rootKind, true))
 266    }
 267    if (rootUrl) {
 268      tags.push(buildITag(rootUrl, true))
 269    }
 270    tags.push(
 271      ...(parentEvent
 272        ? [
 273            isReplaceableEvent(parentEvent.kind)
 274              ? buildATag(parentEvent)
 275              : buildETag(parentEvent.id, parentEvent.pubkey),
 276            buildPTag(parentEvent.pubkey)
 277          ]
 278        : externalContent
 279          ? [buildITag(externalContent)]
 280          : [])
 281    )
 282    const parentKind = parentEvent
 283      ? parentEvent.kind
 284      : externalContent
 285        ? determineExternalContentKind(externalContent)
 286        : undefined
 287    if (parentKind) {
 288      tags.push(buildKTag(parentKind))
 289    }
 290  
 291    if (options.addClientTag) {
 292      tags.push(buildClientTag())
 293    }
 294  
 295    if (options.isNsfw) {
 296      tags.push(buildNsfwTag())
 297    }
 298  
 299    if (options.protectedEvent) {
 300      tags.push(buildProtectedTag())
 301    }
 302  
 303    const baseDraft = {
 304      kind: ExtendedKind.COMMENT,
 305      content: transformedEmojisContent,
 306      tags
 307    }
 308    return setDraftEventCache(baseDraft)
 309  }
 310  
 311  // https://github.com/nostr-protocol/nips/blob/master/84.md
 312  export function createHighlightDraftEvent(
 313    highlightedText: string,
 314    comment: string = '',
 315    sourceEvent: Event,
 316    mentions: string[],
 317    options: {
 318      addClientTag?: boolean
 319      protectedEvent?: boolean
 320      isNsfw?: boolean
 321    } = {}
 322  ): TDraftEvent {
 323    const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(comment)
 324    const quoteTags = extractQuoteTags(comment)
 325    const hashtags = extractHashtags(transformedEmojisContent)
 326  
 327    const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
 328  
 329    // imeta tags
 330    const images = extractImagesFromContent(transformedEmojisContent)
 331    if (images && images.length) {
 332      tags.push(...generateImetaTags(images))
 333    }
 334  
 335    // q tags
 336    tags.push(...quoteTags)
 337  
 338    // p tags
 339    tags.push(
 340      ...mentions
 341        .filter((pubkey) => pubkey !== sourceEvent.pubkey)
 342        .map((pubkey) => ['p', pubkey, '', 'mention'])
 343    )
 344  
 345    // Add comment tag if comment exists
 346    if (transformedEmojisContent) {
 347      tags.push(['comment', transformedEmojisContent])
 348    }
 349  
 350    // Add source reference
 351    const hint = client.getEventHint(sourceEvent.id)
 352    if (isReplaceableEvent(sourceEvent.kind)) {
 353      tags.push(['a', getReplaceableCoordinateFromEvent(sourceEvent), hint, 'source'])
 354    } else {
 355      tags.push(['e', sourceEvent.id, hint, 'source'])
 356    }
 357    tags.push(['p', sourceEvent.pubkey, '', 'author'])
 358  
 359    if (options.addClientTag) {
 360      tags.push(buildClientTag())
 361    }
 362  
 363    if (options.isNsfw) {
 364      tags.push(buildNsfwTag())
 365    }
 366  
 367    if (options.protectedEvent) {
 368      tags.push(buildProtectedTag())
 369    }
 370  
 371    const baseDraft = {
 372      kind: kinds.Highlights,
 373      content: highlightedText,
 374      tags
 375    }
 376    return setDraftEventCache(baseDraft)
 377  }
 378  
 379  export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent {
 380    return {
 381      kind: kinds.RelayList,
 382      content: '',
 383      tags: mailboxRelays.map(({ url, scope }) => buildRTag(url, scope)),
 384      created_at: dayjs().unix()
 385    }
 386  }
 387  
 388  export function createFollowListDraftEvent(tags: string[][], content?: string): TDraftEvent {
 389    return {
 390      kind: kinds.Contacts,
 391      content: content ?? '',
 392      created_at: dayjs().unix(),
 393      tags
 394    }
 395  }
 396  
 397  export function createMuteListDraftEvent(tags: string[][], content?: string): TDraftEvent {
 398    return {
 399      kind: kinds.Mutelist,
 400      content: content ?? '',
 401      created_at: dayjs().unix(),
 402      tags
 403    }
 404  }
 405  
 406  export function createProfileDraftEvent(content: string, tags: string[][] = []): TDraftEvent {
 407    return {
 408      kind: kinds.Metadata,
 409      content,
 410      tags,
 411      created_at: dayjs().unix()
 412    }
 413  }
 414  
 415  export function createFavoriteRelaysDraftEvent(
 416    favoriteRelays: string[],
 417    relaySetEventsOrATags: Event[] | string[][]
 418  ): TDraftEvent {
 419    const tags: string[][] = []
 420    favoriteRelays.forEach((url) => {
 421      tags.push(buildRelayTag(url))
 422    })
 423    relaySetEventsOrATags.forEach((eventOrATag) => {
 424      if (Array.isArray(eventOrATag)) {
 425        tags.push(eventOrATag)
 426      } else {
 427        tags.push(buildATag(eventOrATag))
 428      }
 429    })
 430    return {
 431      kind: ExtendedKind.FAVORITE_RELAYS,
 432      content: '',
 433      tags,
 434      created_at: dayjs().unix()
 435    }
 436  }
 437  
 438  export function createSeenNotificationsAtDraftEvent(): TDraftEvent {
 439    return {
 440      kind: kinds.Application,
 441      content: 'Records read time to sync notification status across devices.',
 442      tags: [buildDTag(ApplicationDataKey.NOTIFICATIONS_SEEN_AT)],
 443      created_at: dayjs().unix()
 444    }
 445  }
 446  
 447  export function createSettingsDraftEvent(content: string): TDraftEvent {
 448    return {
 449      kind: kinds.Application,
 450      content,
 451      tags: [buildDTag(ApplicationDataKey.SETTINGS)],
 452      created_at: dayjs().unix()
 453    }
 454  }
 455  
 456  export function createDeletedMessagesDraftEvent(deletedState: TDMDeletedState): TDraftEvent {
 457    return {
 458      kind: kinds.Application,
 459      content: JSON.stringify(deletedState),
 460      tags: [buildDTag(ApplicationDataKey.DM_DELETED_MESSAGES)],
 461      created_at: dayjs().unix()
 462    }
 463  }
 464  
 465  export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraftEvent {
 466    return {
 467      kind: kinds.BookmarkList,
 468      content,
 469      tags,
 470      created_at: dayjs().unix()
 471    }
 472  }
 473  
 474  export function createPinListDraftEvent(tags: string[][], content = ''): TDraftEvent {
 475    return {
 476      kind: kinds.Pinlist,
 477      content,
 478      tags,
 479      created_at: dayjs().unix()
 480    }
 481  }
 482  
 483  export function createUserEmojiListDraftEvent(tags: string[][], content = ''): TDraftEvent {
 484    return {
 485      kind: kinds.UserEmojiList,
 486      content,
 487      tags,
 488      created_at: dayjs().unix()
 489    }
 490  }
 491  
 492  export function createBlossomServerListDraftEvent(servers: string[]): TDraftEvent {
 493    return {
 494      kind: ExtendedKind.BLOSSOM_SERVER_LIST,
 495      content: '',
 496      tags: servers.map((server) => buildServerTag(server)),
 497      created_at: dayjs().unix()
 498    }
 499  }
 500  
 501  export async function createPollDraftEvent(
 502    author: string,
 503    question: string,
 504    mentions: string[],
 505    { isMultipleChoice, relays, options, endsAt }: TPollCreateData,
 506    {
 507      addClientTag,
 508      isNsfw
 509    }: {
 510      addClientTag?: boolean
 511      isNsfw?: boolean
 512    } = {}
 513  ): Promise<TDraftEvent> {
 514    const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(question)
 515    const { quoteTags } = await extractRelatedEventIds(transformedEmojisContent)
 516    const hashtags = extractHashtags(transformedEmojisContent)
 517  
 518    const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
 519  
 520    // imeta tags
 521    const images = extractImagesFromContent(transformedEmojisContent)
 522    if (images && images.length) {
 523      tags.push(...generateImetaTags(images))
 524    }
 525  
 526    // q tags
 527    tags.push(...quoteTags)
 528  
 529    // p tags
 530    tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
 531  
 532    const validOptions = options.filter((opt) => opt.trim())
 533    tags.push(...validOptions.map((option) => ['option', randomString(9), option.trim()]))
 534    tags.push(['polltype', isMultipleChoice ? POLL_TYPE.MULTIPLE_CHOICE : POLL_TYPE.SINGLE_CHOICE])
 535  
 536    if (endsAt) {
 537      tags.push(['endsAt', endsAt.toString()])
 538    }
 539  
 540    if (relays.length) {
 541      relays.forEach((relay) => tags.push(buildRelayTag(relay)))
 542    } else {
 543      const relayList = await client.fetchRelayList(author)
 544      relayList.read.slice(0, 4).forEach((relay) => {
 545        tags.push(buildRelayTag(relay))
 546      })
 547    }
 548  
 549    if (addClientTag) {
 550      tags.push(buildClientTag())
 551    }
 552  
 553    if (isNsfw) {
 554      tags.push(buildNsfwTag())
 555    }
 556  
 557    const baseDraft = {
 558      content: transformedEmojisContent.trim(),
 559      kind: ExtendedKind.POLL,
 560      tags
 561    }
 562    return setDraftEventCache(baseDraft)
 563  }
 564  
 565  export function createPollResponseDraftEvent(
 566    pollEvent: Event,
 567    selectedOptionIds: string[]
 568  ): TDraftEvent {
 569    return {
 570      content: '',
 571      kind: ExtendedKind.POLL_RESPONSE,
 572      tags: [
 573        buildETag(pollEvent.id, pollEvent.pubkey),
 574        buildPTag(pollEvent.pubkey),
 575        ...selectedOptionIds.map((optionId) => buildResponseTag(optionId))
 576      ],
 577      created_at: dayjs().unix()
 578    }
 579  }
 580  
 581  export function createDeletionRequestDraftEvent(event: Event): TDraftEvent {
 582    const tags: string[][] = [buildKTag(event.kind)]
 583    if (isReplaceableEvent(event.kind)) {
 584      tags.push(['a', getReplaceableCoordinateFromEvent(event)])
 585    } else {
 586      tags.push(['e', event.id])
 587    }
 588  
 589    return {
 590      kind: kinds.EventDeletion,
 591      content: 'Request for deletion of the event.',
 592      tags,
 593      created_at: dayjs().unix()
 594    }
 595  }
 596  
 597  export function createReportDraftEvent(event: Event, reason: string): TDraftEvent {
 598    const tags: string[][] = []
 599    if (event.kind === kinds.Metadata) {
 600      tags.push(['p', event.pubkey, reason])
 601    } else {
 602      tags.push(['p', event.pubkey])
 603      tags.push(['e', event.id, reason])
 604      if (isReplaceableEvent(event.kind)) {
 605        tags.push(['a', getReplaceableCoordinateFromEvent(event), reason])
 606      }
 607    }
 608  
 609    return {
 610      kind: kinds.Report,
 611      content: '',
 612      tags,
 613      created_at: dayjs().unix()
 614    }
 615  }
 616  
 617  export function createRelayReviewDraftEvent(
 618    relay: string,
 619    review: string,
 620    stars: number
 621  ): TDraftEvent {
 622    return {
 623      kind: ExtendedKind.RELAY_REVIEW,
 624      content: review,
 625      tags: [
 626        ['d', relay],
 627        ['rating', (stars / 5).toString()]
 628      ],
 629      created_at: dayjs().unix()
 630    }
 631  }
 632  
 633  // https://github.com/nostr-protocol/nips/blob/master/43.md
 634  export function createJoinDraftEvent(inviteCode: string): TDraftEvent {
 635    return {
 636      kind: 28934,
 637      created_at: Math.floor(Date.now() / 1000),
 638      tags: [['claim', inviteCode], ['-']],
 639      content: ''
 640    }
 641  }
 642  
 643  export function createLeaveDraftEvent(): TDraftEvent {
 644    return {
 645      kind: 28936,
 646      created_at: Math.floor(Date.now() / 1000),
 647      tags: [['-']],
 648      content: ''
 649    }
 650  }
 651  
 652  function generateImetaTags(imageUrls: string[]) {
 653    return imageUrls
 654      .map((imageUrl) => {
 655        const tag = mediaUpload.getImetaTagByUrl(imageUrl)
 656        return tag ?? null
 657      })
 658      .filter(Boolean) as string[][]
 659  }
 660  
 661  async function extractRelatedEventIds(content: string, parentEvent?: Event) {
 662    let rootTag: string[] | null = null
 663    let parentTag: string[] | null = null
 664  
 665    const quoteTags = extractQuoteTags(content)
 666  
 667    if (parentEvent) {
 668      const _rootTag = getRootTag(parentEvent)
 669      if (_rootTag?.type === 'e') {
 670        parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
 671  
 672        const [, rootEventHexId, hint, , rootEventPubkey] = _rootTag.tag
 673        if (rootEventPubkey) {
 674          rootTag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
 675        } else {
 676          const rootEventId = generateBech32IdFromETag(_rootTag.tag)
 677          const rootEvent = rootEventId ? await client.fetchEvent(rootEventId) : undefined
 678          rootTag = rootEvent
 679            ? buildETagWithMarker(rootEvent.id, rootEvent.pubkey, hint, 'root')
 680            : buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
 681        }
 682      } else if (_rootTag?.type === 'a') {
 683        // Legacy
 684        parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
 685        const [, coordinate, hint] = _rootTag.tag
 686        rootTag = buildLegacyRootATag(coordinate, hint)
 687      } else {
 688        // reply to root event
 689        rootTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root')
 690      }
 691    }
 692  
 693    return {
 694      quoteTags,
 695      rootTag,
 696      parentTag
 697    }
 698  }
 699  
 700  async function extractCommentMentions(content: string, parentStuff: Event | string) {
 701    const { parentEvent, externalContent } =
 702      typeof parentStuff === 'string'
 703        ? { parentEvent: undefined, externalContent: parentStuff }
 704        : { parentEvent: parentStuff, externalContent: undefined }
 705    const isComment =
 706      parentEvent && [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
 707    const rootCoordinateTag = parentEvent
 708      ? isComment
 709        ? parentEvent.tags.find(tagNameEquals('A'))
 710        : isReplaceableEvent(parentEvent.kind)
 711          ? buildATag(parentEvent, true)
 712          : undefined
 713      : undefined
 714    const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent?.id
 715    const rootKind = isComment
 716      ? parentEvent.tags.find(tagNameEquals('K'))?.[1]
 717      : parentEvent
 718        ? parentEvent.kind
 719        : determineExternalContentKind(parentStuff as string)
 720    const rootPubkey = isComment
 721      ? parentEvent.tags.find(tagNameEquals('P'))?.[1]
 722      : parentEvent?.pubkey
 723    const rootUrl = isComment ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : externalContent
 724  
 725    const quoteTags = extractQuoteTags(content)
 726  
 727    return {
 728      quoteTags,
 729      rootEventId,
 730      rootCoordinateTag,
 731      rootKind,
 732      rootPubkey,
 733      rootUrl,
 734      parentEvent,
 735      externalContent
 736    }
 737  }
 738  
 739  function extractQuoteTags(content: string) {
 740    const quoteSet = new Set<string>()
 741    const quoteTags: string[][] = []
 742    const matches = content.match(EMBEDDED_EVENT_REGEX)
 743    for (const m of matches || []) {
 744      try {
 745        const id = m.split(':')[1]
 746        const { type, data } = nip19.decode(id)
 747        if (type === 'nevent') {
 748          const id = data.id
 749          if (!quoteSet.has(id)) {
 750            quoteSet.add(id)
 751            const relay = data.relays?.[0] ?? client.getEventHint(id)
 752            quoteTags.push(buildQTag(id, relay, data.author))
 753          }
 754        } else if (type === 'note') {
 755          const id = data
 756          if (!quoteSet.has(id)) {
 757            quoteSet.add(id)
 758            const relay = client.getEventHint(id)
 759            quoteTags.push(buildQTag(id, relay))
 760          }
 761        } else if (type === 'naddr') {
 762          const coordinate = getReplaceableCoordinate(data.kind, data.pubkey, data.identifier)
 763          if (!quoteSet.has(coordinate)) {
 764            quoteSet.add(coordinate)
 765            const relay = data.relays?.[0]
 766            quoteTags.push(buildQTag(coordinate, relay))
 767          }
 768        }
 769      } catch (e) {
 770        console.error(e)
 771      }
 772    }
 773  
 774    return quoteTags
 775  }
 776  
 777  function extractHashtags(content: string) {
 778    const hashtags: string[] = []
 779    const matches = content.match(/#[\p{L}\p{N}\p{M}]+/gu)
 780    matches?.forEach((m) => {
 781      const hashtag = m.slice(1).toLowerCase()
 782      if (hashtag) {
 783        hashtags.push(hashtag)
 784      }
 785    })
 786    return hashtags
 787  }
 788  
 789  function extractImagesFromContent(content: string) {
 790    return content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi)
 791  }
 792  
 793  export function transformCustomEmojisInContent(content: string) {
 794    const emojiTags: string[][] = []
 795    let processedContent = content
 796    const matches = content.match(/:[a-zA-Z0-9]+:/g)
 797  
 798    const emojiIdSet = new Set<string>()
 799    matches?.forEach((m) => {
 800      if (emojiIdSet.has(m)) return
 801      emojiIdSet.add(m)
 802  
 803      const emoji = customEmojiService.getEmojiById(m.slice(1, -1))
 804      if (emoji) {
 805        emojiTags.push(buildEmojiTag(emoji))
 806        processedContent = processedContent.replace(new RegExp(m, 'g'), `:${emoji.shortcode}:`)
 807      }
 808    })
 809  
 810    return {
 811      emojiTags,
 812      content: processedContent
 813    }
 814  }
 815  
 816  export function buildATag(event: Event, upperCase: boolean = false) {
 817    const coordinate = getReplaceableCoordinateFromEvent(event)
 818    const hint = client.getEventHint(event.id)
 819    return trimTagEnd([upperCase ? 'A' : 'a', coordinate, hint])
 820  }
 821  
 822  function buildDTag(identifier: string) {
 823    return ['d', identifier]
 824  }
 825  
 826  export function buildETag(
 827    eventHexId: string,
 828    pubkey: string = '',
 829    hint: string = '',
 830    upperCase: boolean = false
 831  ) {
 832    if (!hint) {
 833      hint = client.getEventHint(eventHexId)
 834    }
 835    return trimTagEnd([upperCase ? 'E' : 'e', eventHexId, hint, pubkey])
 836  }
 837  
 838  function buildETagWithMarker(
 839    eventHexId: string,
 840    pubkey: string = '',
 841    hint: string = '',
 842    marker: 'root' | 'reply' | '' = ''
 843  ) {
 844    if (!hint) {
 845      hint = client.getEventHint(eventHexId)
 846    }
 847    return trimTagEnd(['e', eventHexId, hint, marker, pubkey])
 848  }
 849  
 850  function buildLegacyRootATag(coordinate: string, hint: string = '') {
 851    if (!hint) {
 852      const evt = client.getReplaeableEventFromCache(coordinate)
 853      if (evt) {
 854        hint = client.getEventHint(evt.id)
 855      }
 856    }
 857    return trimTagEnd(['a', coordinate, hint, 'root'])
 858  }
 859  
 860  function buildITag(url: string, upperCase: boolean = false) {
 861    return [upperCase ? 'I' : 'i', url]
 862  }
 863  
 864  function buildKTag(kind: number | string, upperCase: boolean = false) {
 865    return [upperCase ? 'K' : 'k', kind.toString()]
 866  }
 867  
 868  function buildPTag(pubkey: string, upperCase: boolean = false) {
 869    return [upperCase ? 'P' : 'p', pubkey]
 870  }
 871  
 872  function buildQTag(eventHexIdOrCoordinate: string, relay?: string, pubkey?: string) {
 873    const tag: string[] = ['q', eventHexIdOrCoordinate]
 874    if (!relay) {
 875      return tag
 876    }
 877    tag.push(relay)
 878    if (!pubkey) {
 879      return tag
 880    }
 881    tag.push(pubkey)
 882    return tag
 883  }
 884  
 885  function buildRTag(url: string, scope: TMailboxRelayScope) {
 886    return scope !== 'both' ? ['r', url, scope] : ['r', url]
 887  }
 888  
 889  function buildTTag(hashtag: string) {
 890    return ['t', hashtag]
 891  }
 892  
 893  function buildEmojiTag(emoji: TEmoji) {
 894    return ['emoji', emoji.shortcode, emoji.url]
 895  }
 896  
 897  function buildTitleTag(title: string) {
 898    return ['title', title]
 899  }
 900  
 901  function buildRelayTag(url: string) {
 902    return ['relay', url]
 903  }
 904  
 905  function buildServerTag(url: string) {
 906    return ['server', url]
 907  }
 908  
 909  function buildResponseTag(value: string) {
 910    return ['response', value]
 911  }
 912  
 913  function buildClientTag() {
 914    return ['client', 'smesh', 'https://smesh.mleku.dev']
 915  }
 916  
 917  function buildNsfwTag() {
 918    return ['content-warning', 'NSFW']
 919  }
 920  
 921  function buildProtectedTag() {
 922    return ['-']
 923  }
 924  
 925  function trimTagEnd(tag: string[]) {
 926    let endIndex = tag.length - 1
 927    while (endIndex >= 0 && tag[endIndex] === '') {
 928      endIndex--
 929    }
 930  
 931    return tag.slice(0, endIndex + 1)
 932  }
 933