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