Mention.ts raw

   1  import { nip19 } from 'nostr-tools'
   2  import { Pubkey } from '../shared/value-objects/Pubkey'
   3  import { RelayUrl } from '../shared/value-objects/RelayUrl'
   4  
   5  /**
   6   * Mention type indicating how the user was referenced
   7   */
   8  export type MentionType = 'tag' | 'inline' | 'reply_author' | 'quote_author'
   9  
  10  /**
  11   * Mention Value Object
  12   *
  13   * Represents a user mention in a note.
  14   * Handles different mention types and tag generation.
  15   *
  16   * Mention types:
  17   * - tag: Explicit p tag mention
  18   * - inline: nostr:npub or nostr:nprofile in content
  19   * - reply_author: Author of the note being replied to
  20   * - quote_author: Author of the note being quoted
  21   */
  22  export class Mention {
  23    private constructor(
  24      private readonly _pubkey: Pubkey,
  25      private readonly _type: MentionType,
  26      private readonly _relayHint: RelayUrl | null,
  27      private readonly _displayName: string | null
  28    ) {}
  29  
  30    /**
  31     * Create a tag mention (from p tag)
  32     */
  33    static tag(pubkey: Pubkey, relayHint?: RelayUrl): Mention {
  34      return new Mention(pubkey, 'tag', relayHint ?? null, null)
  35    }
  36  
  37    /**
  38     * Create an inline mention (from content)
  39     */
  40    static inline(pubkey: Pubkey, displayName?: string): Mention {
  41      return new Mention(pubkey, 'inline', null, displayName ?? null)
  42    }
  43  
  44    /**
  45     * Create a reply author mention
  46     */
  47    static replyAuthor(pubkey: Pubkey, relayHint?: RelayUrl): Mention {
  48      return new Mention(pubkey, 'reply_author', relayHint ?? null, null)
  49    }
  50  
  51    /**
  52     * Create a quote author mention
  53     */
  54    static quoteAuthor(pubkey: Pubkey, relayHint?: RelayUrl): Mention {
  55      return new Mention(pubkey, 'quote_author', relayHint ?? null, null)
  56    }
  57  
  58    /**
  59     * Parse mentions from content text
  60     * Extracts nostr:npub and nostr:nprofile references
  61     */
  62    static parseFromContent(content: string): Mention[] {
  63      const mentions: Mention[] = []
  64      const seenPubkeys = new Set<string>()
  65  
  66      // Match nostr:npub1... and nostr:nprofile1...
  67      const regex = /nostr:(npub1[a-z0-9]+|nprofile1[a-z0-9]+)/gi
  68      const matches = content.matchAll(regex)
  69  
  70      for (const match of matches) {
  71        try {
  72          const { type, data } = nip19.decode(match[1])
  73  
  74          if (type === 'npub') {
  75            const pubkey = Pubkey.tryFromString(data)
  76            if (pubkey && !seenPubkeys.has(pubkey.hex)) {
  77              seenPubkeys.add(pubkey.hex)
  78              mentions.push(Mention.inline(pubkey))
  79            }
  80          } else if (type === 'nprofile') {
  81            const pubkey = Pubkey.tryFromString(data.pubkey)
  82            if (pubkey && !seenPubkeys.has(pubkey.hex)) {
  83              seenPubkeys.add(pubkey.hex)
  84              const relayHint = data.relays?.[0]
  85                ? RelayUrl.tryCreate(data.relays[0])
  86                : null
  87              mentions.push(new Mention(pubkey, 'inline', relayHint, null))
  88            }
  89          }
  90        } catch {
  91          // Skip invalid bech32
  92        }
  93      }
  94  
  95      return mentions
  96    }
  97  
  98    // Getters
  99    get pubkey(): Pubkey {
 100      return this._pubkey
 101    }
 102  
 103    get type(): MentionType {
 104      return this._type
 105    }
 106  
 107    get relayHint(): RelayUrl | null {
 108      return this._relayHint
 109    }
 110  
 111    get displayName(): string | null {
 112      return this._displayName
 113    }
 114  
 115    get isExplicitTag(): boolean {
 116      return this._type === 'tag'
 117    }
 118  
 119    get isInline(): boolean {
 120      return this._type === 'inline'
 121    }
 122  
 123    get isFromContext(): boolean {
 124      return this._type === 'reply_author' || this._type === 'quote_author'
 125    }
 126  
 127    /**
 128     * Generate the nostr:npub or nostr:nprofile URI for this mention
 129     */
 130    toNostrUri(): string {
 131      if (this._relayHint) {
 132        const nprofile = nip19.nprofileEncode({
 133          pubkey: this._pubkey.hex,
 134          relays: [this._relayHint.value]
 135        })
 136        return `nostr:${nprofile}`
 137      }
 138      return `nostr:${this._pubkey.npub}`
 139    }
 140  
 141    /**
 142     * Generate the p tag for this mention
 143     */
 144    toTag(): string[] {
 145      const tag = ['p', this._pubkey.hex]
 146      if (this._relayHint) {
 147        tag.push(this._relayHint.value)
 148      }
 149      return tag
 150    }
 151  
 152    /**
 153     * Add a relay hint
 154     */
 155    withRelayHint(relay: RelayUrl): Mention {
 156      return new Mention(this._pubkey, this._type, relay, this._displayName)
 157    }
 158  
 159    /**
 160     * Add display name
 161     */
 162    withDisplayName(name: string): Mention {
 163      return new Mention(this._pubkey, this._type, this._relayHint, name)
 164    }
 165  
 166    /**
 167     * Check equality (by pubkey only)
 168     */
 169    equals(other: Mention): boolean {
 170      return this._pubkey.hex === other._pubkey.hex
 171    }
 172  
 173    /**
 174     * Check if this mention has the same pubkey as another
 175     */
 176    hasSamePubkey(pubkey: Pubkey): boolean {
 177      return this._pubkey.hex === pubkey.hex
 178    }
 179  }
 180  
 181  /**
 182   * Collection of mentions with deduplication
 183   */
 184  export class MentionList {
 185    private constructor(private readonly _mentions: readonly Mention[]) {}
 186  
 187    /**
 188     * Create empty mention list
 189     */
 190    static empty(): MentionList {
 191      return new MentionList([])
 192    }
 193  
 194    /**
 195     * Create from array of mentions (deduplicates)
 196     */
 197    static from(mentions: Mention[]): MentionList {
 198      const seen = new Set<string>()
 199      const unique: Mention[] = []
 200  
 201      for (const mention of mentions) {
 202        if (!seen.has(mention.pubkey.hex)) {
 203          seen.add(mention.pubkey.hex)
 204          unique.push(mention)
 205        }
 206      }
 207  
 208      return new MentionList(unique)
 209    }
 210  
 211    get mentions(): readonly Mention[] {
 212      return this._mentions
 213    }
 214  
 215    get length(): number {
 216      return this._mentions.length
 217    }
 218  
 219    get isEmpty(): boolean {
 220      return this._mentions.length === 0
 221    }
 222  
 223    /**
 224     * Get all pubkeys
 225     */
 226    get pubkeys(): Pubkey[] {
 227      return this._mentions.map((m) => m.pubkey)
 228    }
 229  
 230    /**
 231     * Add a mention (returns new list)
 232     */
 233    add(mention: Mention): MentionList {
 234      if (this.contains(mention.pubkey)) {
 235        return this
 236      }
 237      return new MentionList([...this._mentions, mention])
 238    }
 239  
 240    /**
 241     * Remove a mention by pubkey (returns new list)
 242     */
 243    remove(pubkey: Pubkey): MentionList {
 244      return new MentionList(
 245        this._mentions.filter((m) => m.pubkey.hex !== pubkey.hex)
 246      )
 247    }
 248  
 249    /**
 250     * Check if a pubkey is mentioned
 251     */
 252    contains(pubkey: Pubkey): boolean {
 253      return this._mentions.some((m) => m.pubkey.hex === pubkey.hex)
 254    }
 255  
 256    /**
 257     * Generate all p tags
 258     */
 259    toTags(): string[][] {
 260      return this._mentions.map((m) => m.toTag())
 261    }
 262  
 263    /**
 264     * Merge with another mention list
 265     */
 266    merge(other: MentionList): MentionList {
 267      return MentionList.from([...this._mentions, ...other._mentions])
 268    }
 269  }
 270