Note.ts raw

   1  import { Event, kinds } from 'nostr-tools'
   2  import { EventId, Pubkey, Timestamp } from '../shared'
   3  
   4  /**
   5   * Types of notes based on their relationship to other content
   6   */
   7  export type NoteType = 'root' | 'reply' | 'quote'
   8  
   9  /**
  10   * Mention extracted from note tags
  11   */
  12  export type NoteMention = {
  13    pubkey: Pubkey
  14    relayHint?: string
  15    marker?: 'reply' | 'root' | 'mention'
  16  }
  17  
  18  /**
  19   * Reference to another note
  20   */
  21  export type NoteReference = {
  22    eventId: EventId
  23    relayHint?: string
  24    marker?: 'reply' | 'root' | 'mention'
  25    author?: Pubkey
  26  }
  27  
  28  /**
  29   * Note Entity
  30   *
  31   * Represents a short text note (kind 1) in Nostr.
  32   * Wraps the raw Event with rich domain behavior.
  33   *
  34   * This is a read-only entity - it represents a published note.
  35   * For creating notes, use NoteBuilder.
  36   */
  37  export class Note {
  38    private readonly _mentions: NoteMention[]
  39    private readonly _references: NoteReference[]
  40    private readonly _hashtags: string[]
  41  
  42    private constructor(
  43      private readonly _event: Event,
  44      mentions: NoteMention[],
  45      references: NoteReference[],
  46      hashtags: string[]
  47    ) {
  48      this._mentions = mentions
  49      this._references = references
  50      this._hashtags = hashtags
  51    }
  52  
  53    /**
  54     * Create a Note from a Nostr Event
  55     */
  56    static fromEvent(event: Event): Note {
  57      if (event.kind !== kinds.ShortTextNote) {
  58        throw new Error(`Expected kind ${kinds.ShortTextNote}, got ${event.kind}`)
  59      }
  60  
  61      const mentions: NoteMention[] = []
  62      const references: NoteReference[] = []
  63      const hashtags: string[] = []
  64  
  65      for (const tag of event.tags) {
  66        if (tag[0] === 'p' && tag[1]) {
  67          const pubkey = Pubkey.tryFromString(tag[1])
  68          if (pubkey) {
  69            mentions.push({
  70              pubkey,
  71              relayHint: tag[2] || undefined,
  72              marker: tag[3] as NoteMention['marker']
  73            })
  74          }
  75        } else if (tag[0] === 'e' && tag[1]) {
  76          const eventId = EventId.tryFromString(tag[1])
  77          if (eventId) {
  78            const author = tag[4] ? Pubkey.tryFromString(tag[4]) : undefined
  79            references.push({
  80              eventId,
  81              relayHint: tag[2] || undefined,
  82              marker: tag[3] as NoteReference['marker'],
  83              author: author || undefined
  84            })
  85          }
  86        } else if (tag[0] === 't' && tag[1]) {
  87          hashtags.push(tag[1].toLowerCase())
  88        }
  89      }
  90  
  91      return new Note(event, mentions, references, hashtags)
  92    }
  93  
  94    /**
  95     * Try to create a Note from an Event, returns null if invalid
  96     */
  97    static tryFromEvent(event: Event | null | undefined): Note | null {
  98      if (!event) return null
  99      try {
 100        return Note.fromEvent(event)
 101      } catch {
 102        return null
 103      }
 104    }
 105  
 106    /**
 107     * The underlying Nostr event
 108     */
 109    get event(): Event {
 110      return this._event
 111    }
 112  
 113    /**
 114     * The note's event ID
 115     */
 116    get id(): EventId {
 117      return EventId.fromHex(this._event.id)
 118    }
 119  
 120    /**
 121     * The author's public key
 122     */
 123    get author(): Pubkey {
 124      return Pubkey.fromHex(this._event.pubkey)
 125    }
 126  
 127    /**
 128     * The note content
 129     */
 130    get content(): string {
 131      return this._event.content
 132    }
 133  
 134    /**
 135     * When the note was created
 136     */
 137    get createdAt(): Timestamp {
 138      return Timestamp.fromUnix(this._event.created_at)
 139    }
 140  
 141    /**
 142     * All mentioned users
 143     */
 144    get mentions(): NoteMention[] {
 145      return [...this._mentions]
 146    }
 147  
 148    /**
 149     * All referenced notes
 150     */
 151    get references(): NoteReference[] {
 152      return [...this._references]
 153    }
 154  
 155    /**
 156     * All hashtags in the note
 157     */
 158    get hashtags(): string[] {
 159      return [...this._hashtags]
 160    }
 161  
 162    /**
 163     * Get the type of note based on its references
 164     */
 165    get noteType(): NoteType {
 166      const rootRef = this._references.find((r) => r.marker === 'root')
 167      const replyRef = this._references.find((r) => r.marker === 'reply')
 168      const quoteRef = this._event.tags.find((t) => t[0] === 'q')
 169  
 170      if (rootRef || replyRef) {
 171        return 'reply'
 172      }
 173      if (quoteRef) {
 174        return 'quote'
 175      }
 176      return 'root'
 177    }
 178  
 179    /**
 180     * Whether this is a root note (not a reply)
 181     */
 182    get isRoot(): boolean {
 183      return this.noteType === 'root'
 184    }
 185  
 186    /**
 187     * Whether this is a reply to another note
 188     */
 189    get isReply(): boolean {
 190      return this.noteType === 'reply'
 191    }
 192  
 193    /**
 194     * Get the root note reference (if this is a reply)
 195     */
 196    get rootReference(): NoteReference | undefined {
 197      return this._references.find((r) => r.marker === 'root')
 198    }
 199  
 200    /**
 201     * Get the parent note reference (if this is a reply)
 202     */
 203    get parentReference(): NoteReference | undefined {
 204      return this._references.find((r) => r.marker === 'reply')
 205    }
 206  
 207    /**
 208     * Whether this note has a content warning
 209     */
 210    get hasContentWarning(): boolean {
 211      return this._event.tags.some((t) => t[0] === 'content-warning')
 212    }
 213  
 214    /**
 215     * Get the content warning reason (if any)
 216     */
 217    get contentWarning(): string | undefined {
 218      const tag = this._event.tags.find((t) => t[0] === 'content-warning')
 219      return tag?.[1]
 220    }
 221  
 222    /**
 223     * Whether this is an NSFW note
 224     */
 225    get isNsfw(): boolean {
 226      const cwTag = this._event.tags.find((t) => t[0] === 'content-warning')
 227      return cwTag?.[1]?.toLowerCase().includes('nsfw') ?? false
 228    }
 229  
 230    /**
 231     * Whether this note mentions a specific user
 232     */
 233    mentionsUser(pubkey: Pubkey): boolean {
 234      return this._mentions.some((m) => m.pubkey.equals(pubkey))
 235    }
 236  
 237    /**
 238     * Whether this note references a specific event
 239     */
 240    referencesNote(eventId: EventId): boolean {
 241      return this._references.some((r) => r.eventId.equals(eventId))
 242    }
 243  
 244    /**
 245     * Whether this note includes a specific hashtag
 246     */
 247    hasHashtag(hashtag: string): boolean {
 248      return this._hashtags.includes(hashtag.toLowerCase())
 249    }
 250  
 251    /**
 252     * Get mentioned pubkeys as hex strings (for legacy compatibility)
 253     */
 254    getMentionedPubkeysHex(): string[] {
 255      return this._mentions.map((m) => m.pubkey.hex)
 256    }
 257  }
 258