Mention.ts raw
1 import { nip19 } from 'nostr-tools'
2 import { Pubkey } from '../shared/value-objects/Pubkey'
3 import { RelayUrl } from '../shared/value-objects/RelayUrl'
4
5 /**
6 * Mention type indicating how the user was referenced
7 */
8 export type MentionType = 'tag' | 'inline' | 'reply_author' | 'quote_author'
9
10 /**
11 * Mention Value Object
12 *
13 * Represents a user mention in a note.
14 * Handles different mention types and tag generation.
15 *
16 * Mention types:
17 * - tag: Explicit p tag mention
18 * - inline: nostr:npub or nostr:nprofile in content
19 * - reply_author: Author of the note being replied to
20 * - quote_author: Author of the note being quoted
21 */
22 export class Mention {
23 private constructor(
24 private readonly _pubkey: Pubkey,
25 private readonly _type: MentionType,
26 private readonly _relayHint: RelayUrl | null,
27 private readonly _displayName: string | null
28 ) {}
29
30 /**
31 * Create a tag mention (from p tag)
32 */
33 static tag(pubkey: Pubkey, relayHint?: RelayUrl): Mention {
34 return new Mention(pubkey, 'tag', relayHint ?? null, null)
35 }
36
37 /**
38 * Create an inline mention (from content)
39 */
40 static inline(pubkey: Pubkey, displayName?: string): Mention {
41 return new Mention(pubkey, 'inline', null, displayName ?? null)
42 }
43
44 /**
45 * Create a reply author mention
46 */
47 static replyAuthor(pubkey: Pubkey, relayHint?: RelayUrl): Mention {
48 return new Mention(pubkey, 'reply_author', relayHint ?? null, null)
49 }
50
51 /**
52 * Create a quote author mention
53 */
54 static quoteAuthor(pubkey: Pubkey, relayHint?: RelayUrl): Mention {
55 return new Mention(pubkey, 'quote_author', relayHint ?? null, null)
56 }
57
58 /**
59 * Parse mentions from content text
60 * Extracts nostr:npub and nostr:nprofile references
61 */
62 static parseFromContent(content: string): Mention[] {
63 const mentions: Mention[] = []
64 const seenPubkeys = new Set<string>()
65
66 // Match nostr:npub1... and nostr:nprofile1...
67 const regex = /nostr:(npub1[a-z0-9]+|nprofile1[a-z0-9]+)/gi
68 const matches = content.matchAll(regex)
69
70 for (const match of matches) {
71 try {
72 const { type, data } = nip19.decode(match[1])
73
74 if (type === 'npub') {
75 const pubkey = Pubkey.tryFromString(data)
76 if (pubkey && !seenPubkeys.has(pubkey.hex)) {
77 seenPubkeys.add(pubkey.hex)
78 mentions.push(Mention.inline(pubkey))
79 }
80 } else if (type === 'nprofile') {
81 const pubkey = Pubkey.tryFromString(data.pubkey)
82 if (pubkey && !seenPubkeys.has(pubkey.hex)) {
83 seenPubkeys.add(pubkey.hex)
84 const relayHint = data.relays?.[0]
85 ? RelayUrl.tryCreate(data.relays[0])
86 : null
87 mentions.push(new Mention(pubkey, 'inline', relayHint, null))
88 }
89 }
90 } catch {
91 // Skip invalid bech32
92 }
93 }
94
95 return mentions
96 }
97
98 // Getters
99 get pubkey(): Pubkey {
100 return this._pubkey
101 }
102
103 get type(): MentionType {
104 return this._type
105 }
106
107 get relayHint(): RelayUrl | null {
108 return this._relayHint
109 }
110
111 get displayName(): string | null {
112 return this._displayName
113 }
114
115 get isExplicitTag(): boolean {
116 return this._type === 'tag'
117 }
118
119 get isInline(): boolean {
120 return this._type === 'inline'
121 }
122
123 get isFromContext(): boolean {
124 return this._type === 'reply_author' || this._type === 'quote_author'
125 }
126
127 /**
128 * Generate the nostr:npub or nostr:nprofile URI for this mention
129 */
130 toNostrUri(): string {
131 if (this._relayHint) {
132 const nprofile = nip19.nprofileEncode({
133 pubkey: this._pubkey.hex,
134 relays: [this._relayHint.value]
135 })
136 return `nostr:${nprofile}`
137 }
138 return `nostr:${this._pubkey.npub}`
139 }
140
141 /**
142 * Generate the p tag for this mention
143 */
144 toTag(): string[] {
145 const tag = ['p', this._pubkey.hex]
146 if (this._relayHint) {
147 tag.push(this._relayHint.value)
148 }
149 return tag
150 }
151
152 /**
153 * Add a relay hint
154 */
155 withRelayHint(relay: RelayUrl): Mention {
156 return new Mention(this._pubkey, this._type, relay, this._displayName)
157 }
158
159 /**
160 * Add display name
161 */
162 withDisplayName(name: string): Mention {
163 return new Mention(this._pubkey, this._type, this._relayHint, name)
164 }
165
166 /**
167 * Check equality (by pubkey only)
168 */
169 equals(other: Mention): boolean {
170 return this._pubkey.hex === other._pubkey.hex
171 }
172
173 /**
174 * Check if this mention has the same pubkey as another
175 */
176 hasSamePubkey(pubkey: Pubkey): boolean {
177 return this._pubkey.hex === pubkey.hex
178 }
179 }
180
181 /**
182 * Collection of mentions with deduplication
183 */
184 export class MentionList {
185 private constructor(private readonly _mentions: readonly Mention[]) {}
186
187 /**
188 * Create empty mention list
189 */
190 static empty(): MentionList {
191 return new MentionList([])
192 }
193
194 /**
195 * Create from array of mentions (deduplicates)
196 */
197 static from(mentions: Mention[]): MentionList {
198 const seen = new Set<string>()
199 const unique: Mention[] = []
200
201 for (const mention of mentions) {
202 if (!seen.has(mention.pubkey.hex)) {
203 seen.add(mention.pubkey.hex)
204 unique.push(mention)
205 }
206 }
207
208 return new MentionList(unique)
209 }
210
211 get mentions(): readonly Mention[] {
212 return this._mentions
213 }
214
215 get length(): number {
216 return this._mentions.length
217 }
218
219 get isEmpty(): boolean {
220 return this._mentions.length === 0
221 }
222
223 /**
224 * Get all pubkeys
225 */
226 get pubkeys(): Pubkey[] {
227 return this._mentions.map((m) => m.pubkey)
228 }
229
230 /**
231 * Add a mention (returns new list)
232 */
233 add(mention: Mention): MentionList {
234 if (this.contains(mention.pubkey)) {
235 return this
236 }
237 return new MentionList([...this._mentions, mention])
238 }
239
240 /**
241 * Remove a mention by pubkey (returns new list)
242 */
243 remove(pubkey: Pubkey): MentionList {
244 return new MentionList(
245 this._mentions.filter((m) => m.pubkey.hex !== pubkey.hex)
246 )
247 }
248
249 /**
250 * Check if a pubkey is mentioned
251 */
252 contains(pubkey: Pubkey): boolean {
253 return this._mentions.some((m) => m.pubkey.hex === pubkey.hex)
254 }
255
256 /**
257 * Generate all p tags
258 */
259 toTags(): string[][] {
260 return this._mentions.map((m) => m.toTag())
261 }
262
263 /**
264 * Merge with another mention list
265 */
266 merge(other: MentionList): MentionList {
267 return MentionList.from([...this._mentions, ...other._mentions])
268 }
269 }
270