Reaction.ts raw

   1  import { Event, kinds } from 'nostr-tools'
   2  import { EventId, Pubkey, Timestamp } from '../shared'
   3  
   4  /**
   5   * Type of reaction
   6   */
   7  export type ReactionType = 'like' | 'dislike' | 'emoji' | 'custom_emoji'
   8  
   9  /**
  10   * Custom emoji data
  11   */
  12  export type CustomEmoji = {
  13    shortcode: string
  14    url: string
  15  }
  16  
  17  /**
  18   * Reaction Entity
  19   *
  20   * Represents a reaction (kind 7) to a Nostr event.
  21   * Reactions can be likes (+), dislikes (-), emojis, or custom emojis.
  22   */
  23  export class Reaction {
  24    private constructor(
  25      private readonly _event: Event,
  26      private readonly _targetEventId: EventId,
  27      private readonly _targetAuthor: Pubkey,
  28      private readonly _type: ReactionType,
  29      private readonly _emoji: string,
  30      private readonly _customEmoji?: CustomEmoji
  31    ) {}
  32  
  33    /**
  34     * Create a Reaction from a Nostr Event
  35     */
  36    static fromEvent(event: Event): Reaction {
  37      if (event.kind !== kinds.Reaction) {
  38        throw new Error(`Expected kind ${kinds.Reaction}, got ${event.kind}`)
  39      }
  40  
  41      // Find the target event (last 'e' tag)
  42      const eTags = event.tags.filter((t) => t[0] === 'e')
  43      const lastETag = eTags[eTags.length - 1]
  44      if (!lastETag?.[1]) {
  45        throw new Error('Reaction must have an e tag')
  46      }
  47  
  48      // Find the target author (last 'p' tag)
  49      const pTags = event.tags.filter((t) => t[0] === 'p')
  50      const lastPTag = pTags[pTags.length - 1]
  51      if (!lastPTag?.[1]) {
  52        throw new Error('Reaction must have a p tag')
  53      }
  54  
  55      const targetEventId = EventId.fromHex(lastETag[1])
  56      const targetAuthor = Pubkey.fromHex(lastPTag[1])
  57  
  58      // Determine reaction type
  59      const content = event.content
  60      let type: ReactionType
  61      let emoji = content
  62      let customEmoji: CustomEmoji | undefined
  63  
  64      if (content === '+' || content === '') {
  65        type = 'like'
  66        emoji = '+'
  67      } else if (content === '-') {
  68        type = 'dislike'
  69      } else if (content.startsWith(':') && content.endsWith(':')) {
  70        // Custom emoji format :shortcode:
  71        const emojiTag = event.tags.find(
  72          (t) => t[0] === 'emoji' && t[1] === content.slice(1, -1)
  73        )
  74        if (emojiTag?.[2]) {
  75          type = 'custom_emoji'
  76          customEmoji = {
  77            shortcode: emojiTag[1],
  78            url: emojiTag[2]
  79          }
  80        } else {
  81          type = 'emoji'
  82        }
  83      } else {
  84        type = 'emoji'
  85      }
  86  
  87      return new Reaction(event, targetEventId, targetAuthor, type, emoji, customEmoji)
  88    }
  89  
  90    /**
  91     * Try to create a Reaction from an Event, returns null if invalid
  92     */
  93    static tryFromEvent(event: Event | null | undefined): Reaction | null {
  94      if (!event) return null
  95      try {
  96        return Reaction.fromEvent(event)
  97      } catch {
  98        return null
  99      }
 100    }
 101  
 102    /**
 103     * The underlying Nostr event
 104     */
 105    get event(): Event {
 106      return this._event
 107    }
 108  
 109    /**
 110     * The reaction's event ID
 111     */
 112    get id(): EventId {
 113      return EventId.fromHex(this._event.id)
 114    }
 115  
 116    /**
 117     * The author of the reaction
 118     */
 119    get author(): Pubkey {
 120      return Pubkey.fromHex(this._event.pubkey)
 121    }
 122  
 123    /**
 124     * The event ID being reacted to
 125     */
 126    get targetEventId(): EventId {
 127      return this._targetEventId
 128    }
 129  
 130    /**
 131     * The author of the event being reacted to
 132     */
 133    get targetAuthor(): Pubkey {
 134      return this._targetAuthor
 135    }
 136  
 137    /**
 138     * The type of reaction
 139     */
 140    get type(): ReactionType {
 141      return this._type
 142    }
 143  
 144    /**
 145     * The emoji or reaction content
 146     */
 147    get emoji(): string {
 148      return this._emoji
 149    }
 150  
 151    /**
 152     * Custom emoji data (if applicable)
 153     */
 154    get customEmoji(): CustomEmoji | undefined {
 155      return this._customEmoji
 156    }
 157  
 158    /**
 159     * When the reaction was created
 160     */
 161    get createdAt(): Timestamp {
 162      return Timestamp.fromUnix(this._event.created_at)
 163    }
 164  
 165    /**
 166     * Whether this is a like
 167     */
 168    get isLike(): boolean {
 169      return this._type === 'like'
 170    }
 171  
 172    /**
 173     * Whether this is a dislike
 174     */
 175    get isDislike(): boolean {
 176      return this._type === 'dislike'
 177    }
 178  
 179    /**
 180     * Whether this is a positive reaction (like or emoji, not dislike)
 181     */
 182    get isPositive(): boolean {
 183      return this._type !== 'dislike'
 184    }
 185  
 186    /**
 187     * Whether this uses a custom emoji
 188     */
 189    get hasCustomEmoji(): boolean {
 190      return this._type === 'custom_emoji' && !!this._customEmoji
 191    }
 192  
 193    /**
 194     * Get the display value for the reaction
 195     */
 196    get displayValue(): string {
 197      if (this._type === 'like') return '❤️'
 198      if (this._type === 'dislike') return '👎'
 199      if (this._customEmoji) return `:${this._customEmoji.shortcode}:`
 200      return this._emoji
 201    }
 202  }
 203