ReplyContext.ts raw

   1  import { Event } from 'nostr-tools'
   2  import { Pubkey } from '../shared/value-objects/Pubkey'
   3  import { RelayUrl } from '../shared/value-objects/RelayUrl'
   4  
   5  /**
   6   * Information about a referenced event
   7   */
   8  export interface EventReference {
   9    eventId: string
  10    pubkey: Pubkey
  11    relayHint?: RelayUrl
  12  }
  13  
  14  /**
  15   * ReplyContext Value Object
  16   *
  17   * Encapsulates the context needed for creating a reply to a note.
  18   * Handles NIP-10 compliant tagging for proper thread structure.
  19   *
  20   * NIP-10 Threading:
  21   * - Root tag: The original post that started the thread
  22   * - Reply tag: The immediate parent being replied to
  23   * - Mention tags: Other events referenced but not directly replied to
  24   *
  25   * This value object extracts the threading information from events
  26   * and generates proper tags for new replies.
  27   */
  28  export class ReplyContext {
  29    private constructor(
  30      private readonly _rootEvent: EventReference | null,
  31      private readonly _replyToEvent: EventReference,
  32      private readonly _mentionedEvents: readonly EventReference[],
  33      private readonly _mentionedPubkeys: readonly Pubkey[]
  34    ) {}
  35  
  36    /**
  37     * Create a reply context from an event being replied to
  38     *
  39     * Extracts existing thread structure from the event's tags
  40     * to maintain proper threading.
  41     */
  42    static fromEvent(event: Event): ReplyContext {
  43      const replyToPubkey = Pubkey.tryFromString(event.pubkey)
  44      if (!replyToPubkey) {
  45        throw new Error('Invalid pubkey in event being replied to')
  46      }
  47  
  48      // Extract root and other thread info from existing tags
  49      let rootEvent: EventReference | null = null
  50      const mentionedEvents: EventReference[] = []
  51      const mentionedPubkeys: Pubkey[] = []
  52  
  53      for (const tag of event.tags) {
  54        if (tag[0] === 'e' && tag[1]) {
  55          const marker = tag[3]
  56          const eventId = tag[1]
  57          const relayHint = tag[2] ? RelayUrl.tryCreate(tag[2]) : undefined
  58  
  59          // Find the event's author for this reference
  60          // We may not have it, so we'll just use the event pubkey as fallback
  61          const refPubkey = replyToPubkey // Fallback
  62  
  63          if (marker === 'root') {
  64            rootEvent = {
  65              eventId,
  66              pubkey: refPubkey,
  67              relayHint: relayHint ?? undefined
  68            }
  69          } else if (marker === 'mention') {
  70            mentionedEvents.push({
  71              eventId,
  72              pubkey: refPubkey,
  73              relayHint: relayHint ?? undefined
  74            })
  75          }
  76          // Skip 'reply' marker as we'll set the current event as the new reply target
  77        }
  78  
  79        if (tag[0] === 'p' && tag[1]) {
  80          const pk = Pubkey.tryFromString(tag[1])
  81          if (pk) {
  82            mentionedPubkeys.push(pk)
  83          }
  84        }
  85      }
  86  
  87      // The event being replied to becomes the new reply target
  88      const replyToEvent: EventReference = {
  89        eventId: event.id,
  90        pubkey: replyToPubkey
  91      }
  92  
  93      // If the event had no root, it's a top-level post, so it becomes the root
  94      if (!rootEvent) {
  95        rootEvent = replyToEvent
  96      }
  97  
  98      // Add the reply-to author to mentioned pubkeys if not already present
  99      const pubkeySet = new Set(mentionedPubkeys.map((p) => p.hex))
 100      if (!pubkeySet.has(replyToPubkey.hex)) {
 101        mentionedPubkeys.push(replyToPubkey)
 102      }
 103  
 104      return new ReplyContext(rootEvent, replyToEvent, mentionedEvents, mentionedPubkeys)
 105    }
 106  
 107    /**
 108     * Create a simple reply context (no existing thread)
 109     */
 110    static simple(eventId: string, authorPubkey: Pubkey, relayHint?: RelayUrl): ReplyContext {
 111      const ref: EventReference = {
 112        eventId,
 113        pubkey: authorPubkey,
 114        relayHint
 115      }
 116      return new ReplyContext(ref, ref, [], [authorPubkey])
 117    }
 118  
 119    // Getters
 120    get rootEvent(): EventReference | null {
 121      return this._rootEvent
 122    }
 123  
 124    get replyToEvent(): EventReference {
 125      return this._replyToEvent
 126    }
 127  
 128    get mentionedEvents(): readonly EventReference[] {
 129      return this._mentionedEvents
 130    }
 131  
 132    get mentionedPubkeys(): readonly Pubkey[] {
 133      return this._mentionedPubkeys
 134    }
 135  
 136    /**
 137     * Check if this is a reply to a top-level post (not nested)
 138     */
 139    get isDirectReply(): boolean {
 140      return (
 141        this._rootEvent !== null && this._rootEvent.eventId === this._replyToEvent.eventId
 142      )
 143    }
 144  
 145    /**
 146     * Check if this is a nested reply (reply to a reply)
 147     */
 148    get isNestedReply(): boolean {
 149      return (
 150        this._rootEvent !== null && this._rootEvent.eventId !== this._replyToEvent.eventId
 151      )
 152    }
 153  
 154    /**
 155     * Get the thread depth (0 for direct reply to root, 1+ for nested)
 156     */
 157    get depth(): number {
 158      return this._mentionedEvents.length
 159    }
 160  
 161    /**
 162     * Generate NIP-10 compliant tags for a reply
 163     *
 164     * Returns tags in the format:
 165     * - ['e', rootId, relayHint?, 'root']
 166     * - ['e', replyId, relayHint?, 'reply']
 167     * - ['p', pubkey, relayHint?] for each mentioned pubkey
 168     */
 169    toTags(): string[][] {
 170      const tags: string[][] = []
 171  
 172      // Root tag (the original post in the thread)
 173      if (this._rootEvent) {
 174        const rootTag = ['e', this._rootEvent.eventId]
 175        if (this._rootEvent.relayHint) {
 176          rootTag.push(this._rootEvent.relayHint.value)
 177        } else {
 178          rootTag.push('')
 179        }
 180        rootTag.push('root')
 181        tags.push(rootTag)
 182      }
 183  
 184      // Reply tag (the immediate parent)
 185      // Only add if different from root
 186      if (!this._rootEvent || this._rootEvent.eventId !== this._replyToEvent.eventId) {
 187        const replyTag = ['e', this._replyToEvent.eventId]
 188        if (this._replyToEvent.relayHint) {
 189          replyTag.push(this._replyToEvent.relayHint.value)
 190        } else {
 191          replyTag.push('')
 192        }
 193        replyTag.push('reply')
 194        tags.push(replyTag)
 195      } else if (this._rootEvent) {
 196        // If root and reply are the same, use 'reply' marker
 197        // (overwrite the root tag to be 'reply' for direct replies)
 198        tags[0][3] = 'reply'
 199      }
 200  
 201      // Pubkey tags for all mentioned authors
 202      const addedPubkeys = new Set<string>()
 203      for (const pubkey of this._mentionedPubkeys) {
 204        if (!addedPubkeys.has(pubkey.hex)) {
 205          tags.push(['p', pubkey.hex])
 206          addedPubkeys.add(pubkey.hex)
 207        }
 208      }
 209  
 210      return tags
 211    }
 212  
 213    /**
 214     * Add an additional pubkey mention
 215     */
 216    withMentionedPubkey(pubkey: Pubkey): ReplyContext {
 217      const existingHexes = new Set(this._mentionedPubkeys.map((p) => p.hex))
 218      if (existingHexes.has(pubkey.hex)) {
 219        return this
 220      }
 221      return new ReplyContext(
 222        this._rootEvent,
 223        this._replyToEvent,
 224        this._mentionedEvents,
 225        [...this._mentionedPubkeys, pubkey]
 226      )
 227    }
 228  
 229    /**
 230     * Check equality
 231     */
 232    equals(other: ReplyContext): boolean {
 233      if (this._replyToEvent.eventId !== other._replyToEvent.eventId) return false
 234      if (this._rootEvent?.eventId !== other._rootEvent?.eventId) return false
 235      return true
 236    }
 237  }
 238