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