NoteComposer.ts raw

   1  import { Pubkey } from '../shared/value-objects/Pubkey'
   2  import { EventId } from '../shared/value-objects/EventId'
   3  import { ReplyContext } from './ReplyContext'
   4  import { QuoteContext } from './QuoteContext'
   5  import { NoteCreated, NoteReplied, UsersMentioned } from './events'
   6  
   7  /**
   8   * Options for note composition
   9   */
  10  export interface NoteComposerOptions {
  11    isNsfw?: boolean
  12    addClientTag?: boolean
  13    isProtected?: boolean
  14  }
  15  
  16  /**
  17   * Poll option for poll notes
  18   */
  19  export interface PollOption {
  20    id: string
  21    text: string
  22  }
  23  
  24  /**
  25   * Poll configuration
  26   */
  27  export interface PollConfig {
  28    isMultipleChoice: boolean
  29    options: PollOption[]
  30    endsAt?: number
  31    relays: string[]
  32  }
  33  
  34  /**
  35   * Validation result for note composition
  36   */
  37  export interface ValidationResult {
  38    isValid: boolean
  39    errors: string[]
  40  }
  41  
  42  /**
  43   * Extracted content elements from note text
  44   */
  45  export interface ExtractedContent {
  46    hashtags: string[]
  47    mentionedPubkeys: Pubkey[]
  48    quotedEventIds: string[]
  49    imageUrls: string[]
  50  }
  51  
  52  /**
  53   * NoteComposer Aggregate
  54   *
  55   * Represents the state and business logic for composing a new note.
  56   * This is the write-side aggregate for the Feed bounded context.
  57   *
  58   * The NoteComposer handles:
  59   * - Text content with mentions, hashtags, and embedded media
  60   * - Reply threading (using ReplyContext)
  61   * - Quote posts (using QuoteContext)
  62   * - Poll creation
  63   * - Content warnings (NSFW)
  64   * - Validation before publishing
  65   *
  66   * Note: The NoteComposer does NOT handle actual publishing or signing.
  67   * Those are infrastructure concerns handled by the application layer.
  68   *
  69   * Invariants:
  70   * - Author must be set before publishing
  71   * - Content must not be empty (unless it's a repost)
  72   * - Poll must have at least 2 options if enabled
  73   */
  74  export class NoteComposer {
  75    private constructor(
  76      private readonly _author: Pubkey,
  77      private _content: string,
  78      private _replyContext: ReplyContext | null,
  79      private _quoteContext: QuoteContext | null,
  80      private _additionalMentions: readonly Pubkey[],
  81      private _options: NoteComposerOptions,
  82      private _pollConfig: PollConfig | null
  83    ) {}
  84  
  85    // ============================================================================
  86    // Factory Methods
  87    // ============================================================================
  88  
  89    /**
  90     * Create a new note composer for a fresh post
  91     */
  92    static create(author: Pubkey): NoteComposer {
  93      return new NoteComposer(
  94        author,
  95        '',
  96        null,
  97        null,
  98        [],
  99        {
 100          isNsfw: false,
 101          addClientTag: false,
 102          isProtected: false
 103        },
 104        null
 105      )
 106    }
 107  
 108    /**
 109     * Create a note composer for a reply
 110     */
 111    static reply(author: Pubkey, replyTo: ReplyContext): NoteComposer {
 112      return new NoteComposer(
 113        author,
 114        '',
 115        replyTo,
 116        null,
 117        [],
 118        {
 119          isNsfw: false,
 120          addClientTag: false,
 121          isProtected: false
 122        },
 123        null
 124      )
 125    }
 126  
 127    /**
 128     * Create a note composer for a quote post
 129     */
 130    static quote(author: Pubkey, quoteNote: QuoteContext): NoteComposer {
 131      return new NoteComposer(
 132        author,
 133        '',
 134        null,
 135        quoteNote,
 136        [],
 137        {
 138          isNsfw: false,
 139          addClientTag: false,
 140          isProtected: false
 141        },
 142        null
 143      )
 144    }
 145  
 146    /**
 147     * Create a note composer for a poll
 148     */
 149    static poll(author: Pubkey): NoteComposer {
 150      return new NoteComposer(
 151        author,
 152        '',
 153        null,
 154        null,
 155        [],
 156        {
 157          isNsfw: false,
 158          addClientTag: false,
 159          isProtected: false
 160        },
 161        {
 162          isMultipleChoice: false,
 163          options: [],
 164          relays: []
 165        }
 166      )
 167    }
 168  
 169    // ============================================================================
 170    // Queries
 171    // ============================================================================
 172  
 173    get author(): Pubkey {
 174      return this._author
 175    }
 176  
 177    get content(): string {
 178      return this._content
 179    }
 180  
 181    get replyContext(): ReplyContext | null {
 182      return this._replyContext
 183    }
 184  
 185    get quoteContext(): QuoteContext | null {
 186      return this._quoteContext
 187    }
 188  
 189    get additionalMentions(): readonly Pubkey[] {
 190      return this._additionalMentions
 191    }
 192  
 193    get options(): NoteComposerOptions {
 194      return { ...this._options }
 195    }
 196  
 197    get pollConfig(): PollConfig | null {
 198      return this._pollConfig ? { ...this._pollConfig } : null
 199    }
 200  
 201    get isReply(): boolean {
 202      return this._replyContext !== null
 203    }
 204  
 205    get isQuote(): boolean {
 206      return this._quoteContext !== null
 207    }
 208  
 209    get isPoll(): boolean {
 210      return this._pollConfig !== null
 211    }
 212  
 213    get isNsfw(): boolean {
 214      return this._options.isNsfw ?? false
 215    }
 216  
 217    /**
 218     * Get all mentioned pubkeys (from reply context + additional mentions)
 219     */
 220    get allMentions(): Pubkey[] {
 221      const mentions: Pubkey[] = []
 222      const seenHexes = new Set<string>()
 223  
 224      // Add mentions from reply context
 225      if (this._replyContext) {
 226        for (const pk of this._replyContext.mentionedPubkeys) {
 227          if (!seenHexes.has(pk.hex)) {
 228            mentions.push(pk)
 229            seenHexes.add(pk.hex)
 230          }
 231        }
 232      }
 233  
 234      // Add mentions from quote context
 235      if (this._quoteContext) {
 236        const quotedAuthor = this._quoteContext.quotedAuthor
 237        if (!seenHexes.has(quotedAuthor.hex)) {
 238          mentions.push(quotedAuthor)
 239          seenHexes.add(quotedAuthor.hex)
 240        }
 241      }
 242  
 243      // Add additional mentions
 244      for (const pk of this._additionalMentions) {
 245        if (!seenHexes.has(pk.hex)) {
 246          mentions.push(pk)
 247          seenHexes.add(pk.hex)
 248        }
 249      }
 250  
 251      return mentions
 252    }
 253  
 254    /**
 255     * Extract hashtags from content
 256     */
 257    get hashtags(): string[] {
 258      const matches = this._content.match(/#[\p{L}\p{N}\p{M}]+/gu)
 259      if (!matches) return []
 260      return matches.map((m) => m.slice(1).toLowerCase()).filter(Boolean)
 261    }
 262  
 263    /**
 264     * Get the effective content for publishing
 265     * (includes quote URI if quoting)
 266     */
 267    get effectiveContent(): string {
 268      if (this._quoteContext) {
 269        return this._quoteContext.appendToContent(this._content)
 270      }
 271      return this._content
 272    }
 273  
 274    // ============================================================================
 275    // Commands (Immutable - return new instances)
 276    // ============================================================================
 277  
 278    /**
 279     * Set the content text
 280     */
 281    setContent(content: string): NoteComposer {
 282      return new NoteComposer(
 283        this._author,
 284        content,
 285        this._replyContext,
 286        this._quoteContext,
 287        this._additionalMentions,
 288        this._options,
 289        this._pollConfig
 290      )
 291    }
 292  
 293    /**
 294     * Add a mention
 295     */
 296    addMention(pubkey: Pubkey): NoteComposer {
 297      // Check if already mentioned
 298      if (this._additionalMentions.some((p) => p.hex === pubkey.hex)) {
 299        return this
 300      }
 301  
 302      return new NoteComposer(
 303        this._author,
 304        this._content,
 305        this._replyContext,
 306        this._quoteContext,
 307        [...this._additionalMentions, pubkey],
 308        this._options,
 309        this._pollConfig
 310      )
 311    }
 312  
 313    /**
 314     * Remove a mention
 315     */
 316    removeMention(pubkey: Pubkey): NoteComposer {
 317      return new NoteComposer(
 318        this._author,
 319        this._content,
 320        this._replyContext,
 321        this._quoteContext,
 322        this._additionalMentions.filter((p) => p.hex !== pubkey.hex),
 323        this._options,
 324        this._pollConfig
 325      )
 326    }
 327  
 328    /**
 329     * Set content warning (NSFW)
 330     */
 331    setContentWarning(isNsfw: boolean): NoteComposer {
 332      return new NoteComposer(
 333        this._author,
 334        this._content,
 335        this._replyContext,
 336        this._quoteContext,
 337        this._additionalMentions,
 338        { ...this._options, isNsfw },
 339        this._pollConfig
 340      )
 341    }
 342  
 343    /**
 344     * Set client tag option
 345     */
 346    setClientTag(addClientTag: boolean): NoteComposer {
 347      return new NoteComposer(
 348        this._author,
 349        this._content,
 350        this._replyContext,
 351        this._quoteContext,
 352        this._additionalMentions,
 353        { ...this._options, addClientTag },
 354        this._pollConfig
 355      )
 356    }
 357  
 358    /**
 359     * Set protected event option
 360     */
 361    setProtected(isProtected: boolean): NoteComposer {
 362      return new NoteComposer(
 363        this._author,
 364        this._content,
 365        this._replyContext,
 366        this._quoteContext,
 367        this._additionalMentions,
 368        { ...this._options, isProtected },
 369        this._pollConfig
 370      )
 371    }
 372  
 373    /**
 374     * Enable poll mode with configuration
 375     */
 376    enablePoll(config: PollConfig): NoteComposer {
 377      // Polls can't be replies
 378      return new NoteComposer(
 379        this._author,
 380        this._content,
 381        null, // Clear reply context
 382        this._quoteContext,
 383        this._additionalMentions,
 384        this._options,
 385        config
 386      )
 387    }
 388  
 389    /**
 390     * Disable poll mode
 391     */
 392    disablePoll(): NoteComposer {
 393      return new NoteComposer(
 394        this._author,
 395        this._content,
 396        this._replyContext,
 397        this._quoteContext,
 398        this._additionalMentions,
 399        this._options,
 400        null
 401      )
 402    }
 403  
 404    /**
 405     * Update poll options
 406     */
 407    setPollOptions(options: PollOption[]): NoteComposer {
 408      if (!this._pollConfig) return this
 409  
 410      return new NoteComposer(
 411        this._author,
 412        this._content,
 413        this._replyContext,
 414        this._quoteContext,
 415        this._additionalMentions,
 416        this._options,
 417        { ...this._pollConfig, options }
 418      )
 419    }
 420  
 421    /**
 422     * Set poll multiple choice mode
 423     */
 424    setPollMultipleChoice(isMultipleChoice: boolean): NoteComposer {
 425      if (!this._pollConfig) return this
 426  
 427      return new NoteComposer(
 428        this._author,
 429        this._content,
 430        this._replyContext,
 431        this._quoteContext,
 432        this._additionalMentions,
 433        this._options,
 434        { ...this._pollConfig, isMultipleChoice }
 435      )
 436    }
 437  
 438    // ============================================================================
 439    // Validation
 440    // ============================================================================
 441  
 442    /**
 443     * Validate the note is ready for publishing
 444     */
 445    validate(): ValidationResult {
 446      const errors: string[] = []
 447  
 448      // Content must not be empty (for regular posts)
 449      if (!this._content.trim() && !this.isPoll) {
 450        errors.push('Content cannot be empty')
 451      }
 452  
 453      // Poll validation
 454      if (this._pollConfig) {
 455        const validOptions = this._pollConfig.options.filter((opt) => opt.text.trim())
 456        if (validOptions.length < 2) {
 457          errors.push('Poll must have at least 2 options')
 458        }
 459        if (!this._content.trim()) {
 460          errors.push('Poll question cannot be empty')
 461        }
 462      }
 463  
 464      return {
 465        isValid: errors.length === 0,
 466        errors
 467      }
 468    }
 469  
 470    /**
 471     * Check if the note can be published
 472     */
 473    canPublish(): boolean {
 474      return this.validate().isValid
 475    }
 476  
 477    // ============================================================================
 478    // Domain Events
 479    // ============================================================================
 480  
 481    /**
 482     * Create the NoteCreated domain event
 483     * Call this after successful publishing
 484     */
 485    createNoteCreatedEvent(noteId: EventId): NoteCreated {
 486      return new NoteCreated(
 487        this._author,
 488        noteId,
 489        this._replyContext?.replyToEvent
 490          ? EventId.tryFromString(this._replyContext.replyToEvent.eventId)
 491          : null,
 492        this._quoteContext
 493          ? EventId.tryFromString(this._quoteContext.quotedEventId)
 494          : null,
 495        this.allMentions,
 496        this.hashtags
 497      )
 498    }
 499  
 500    /**
 501     * Create the NoteReplied domain event (if this is a reply)
 502     * Call this after successful publishing
 503     */
 504    createNoteRepliedEvent(replyNoteId: EventId): NoteReplied | null {
 505      if (!this._replyContext) return null
 506  
 507      const originalNoteId = EventId.tryFromString(this._replyContext.replyToEvent.eventId)
 508      if (!originalNoteId) return null
 509  
 510      return new NoteReplied(
 511        originalNoteId,
 512        this._replyContext.replyToEvent.pubkey,
 513        replyNoteId,
 514        this._author
 515      )
 516    }
 517  
 518    /**
 519     * Create the UsersMentioned domain event (if there are mentions)
 520     * Call this after successful publishing
 521     */
 522    createUsersMentionedEvent(noteId: EventId): UsersMentioned | null {
 523      const mentions = this.allMentions
 524      if (mentions.length === 0) return null
 525  
 526      return new UsersMentioned(noteId, this._author, mentions)
 527    }
 528  }
 529