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