QuoteContext.ts raw

   1  import { Event, nip19 } from 'nostr-tools'
   2  import { Pubkey } from '../shared/value-objects/Pubkey'
   3  import { RelayUrl } from '../shared/value-objects/RelayUrl'
   4  
   5  /**
   6   * QuoteContext Value Object
   7   *
   8   * Encapsulates the context needed for quoting another note.
   9   * Handles NIP-27 (nostr: URI) generation and proper tagging.
  10   *
  11   * Unlike replies, quotes are standalone posts that reference
  12   * another event. The referenced event is shown inline in the
  13   * quoting post.
  14   *
  15   * Tags used:
  16   * - 'q' tag: The quoted event ID (NIP-18)
  17   * - 'p' tag: The quoted author's pubkey
  18   *
  19   * The quote is inserted into content using nostr:nevent1... format.
  20   */
  21  export class QuoteContext {
  22    private constructor(
  23      private readonly _quotedEventId: string,
  24      private readonly _quotedAuthor: Pubkey,
  25      private readonly _relayHints: readonly RelayUrl[],
  26      private readonly _quotedKind?: number
  27    ) {}
  28  
  29    /**
  30     * Create a quote context from an event
  31     */
  32    static fromEvent(event: Event, relayHints: RelayUrl[] = []): QuoteContext {
  33      const authorPubkey = Pubkey.tryFromString(event.pubkey)
  34      if (!authorPubkey) {
  35        throw new Error('Invalid pubkey in event being quoted')
  36      }
  37  
  38      return new QuoteContext(event.id, authorPubkey, relayHints, event.kind)
  39    }
  40  
  41    /**
  42     * Create a quote context from components
  43     */
  44    static create(
  45      eventId: string,
  46      author: Pubkey,
  47      relayHints: RelayUrl[] = [],
  48      kind?: number
  49    ): QuoteContext {
  50      return new QuoteContext(eventId, author, relayHints, kind)
  51    }
  52  
  53    // Getters
  54    get quotedEventId(): string {
  55      return this._quotedEventId
  56    }
  57  
  58    get quotedAuthor(): Pubkey {
  59      return this._quotedAuthor
  60    }
  61  
  62    get relayHints(): readonly RelayUrl[] {
  63      return this._relayHints
  64    }
  65  
  66    get quotedKind(): number | undefined {
  67      return this._quotedKind
  68    }
  69  
  70    /**
  71     * Generate the nostr:nevent1... URI for embedding in content
  72     *
  73     * Uses NIP-19 nevent encoding which includes:
  74     * - Event ID
  75     * - Relay hints (for fetching)
  76     * - Author pubkey (for verification)
  77     * - Kind (optional, for context)
  78     */
  79    toNostrUri(): string {
  80      const nevent = nip19.neventEncode({
  81        id: this._quotedEventId,
  82        relays: this._relayHints.map((r) => r.value),
  83        author: this._quotedAuthor.hex,
  84        kind: this._quotedKind
  85      })
  86      return `nostr:${nevent}`
  87    }
  88  
  89    /**
  90     * Generate the simple note reference (nostr:note1...)
  91     * Use this for simpler clients that don't support nevent
  92     */
  93    toSimpleNostrUri(): string {
  94      const note = nip19.noteEncode(this._quotedEventId)
  95      return `nostr:${note}`
  96    }
  97  
  98    /**
  99     * Generate tags for a quote post
 100     *
 101     * Returns:
 102     * - ['q', eventId, relayHint?] - The quoted event
 103     * - ['p', pubkey] - The quoted author
 104     */
 105    toTags(): string[][] {
 106      const tags: string[][] = []
 107  
 108      // Quote tag (NIP-18)
 109      const quoteTag = ['q', this._quotedEventId]
 110      if (this._relayHints.length > 0) {
 111        quoteTag.push(this._relayHints[0].value)
 112      }
 113      tags.push(quoteTag)
 114  
 115      // Pubkey tag for the quoted author
 116      tags.push(['p', this._quotedAuthor.hex])
 117  
 118      return tags
 119    }
 120  
 121    /**
 122     * Append the quote to content
 123     *
 124     * Adds a newline and the nostr: URI to the end of the content.
 125     * Returns the modified content string.
 126     */
 127    appendToContent(content: string): string {
 128      const uri = this.toNostrUri()
 129      const trimmed = content.trim()
 130  
 131      if (trimmed.length === 0) {
 132        return uri
 133      }
 134  
 135      // Check if content already ends with the URI
 136      if (trimmed.endsWith(uri)) {
 137        return trimmed
 138      }
 139  
 140      return `${trimmed}\n\n${uri}`
 141    }
 142  
 143    /**
 144     * Check if content already contains this quote
 145     */
 146    isInContent(content: string): boolean {
 147      // Check for both nevent and note formats
 148      return (
 149        content.includes(this.toNostrUri()) ||
 150        content.includes(this.toSimpleNostrUri()) ||
 151        content.includes(this._quotedEventId)
 152      )
 153    }
 154  
 155    /**
 156     * Add a relay hint
 157     */
 158    withRelayHint(relay: RelayUrl): QuoteContext {
 159      const existingUrls = new Set(this._relayHints.map((r) => r.value))
 160      if (existingUrls.has(relay.value)) {
 161        return this
 162      }
 163      return new QuoteContext(
 164        this._quotedEventId,
 165        this._quotedAuthor,
 166        [...this._relayHints, relay],
 167        this._quotedKind
 168      )
 169    }
 170  
 171    /**
 172     * Check equality
 173     */
 174    equals(other: QuoteContext): boolean {
 175      return this._quotedEventId === other._quotedEventId
 176    }
 177  }
 178