Note.ts raw
1 import { Event, kinds } from 'nostr-tools'
2 import { EventId, Pubkey, Timestamp } from '../shared'
3
4 /**
5 * Types of notes based on their relationship to other content
6 */
7 export type NoteType = 'root' | 'reply' | 'quote'
8
9 /**
10 * Mention extracted from note tags
11 */
12 export type NoteMention = {
13 pubkey: Pubkey
14 relayHint?: string
15 marker?: 'reply' | 'root' | 'mention'
16 }
17
18 /**
19 * Reference to another note
20 */
21 export type NoteReference = {
22 eventId: EventId
23 relayHint?: string
24 marker?: 'reply' | 'root' | 'mention'
25 author?: Pubkey
26 }
27
28 /**
29 * Note Entity
30 *
31 * Represents a short text note (kind 1) in Nostr.
32 * Wraps the raw Event with rich domain behavior.
33 *
34 * This is a read-only entity - it represents a published note.
35 * For creating notes, use NoteBuilder.
36 */
37 export class Note {
38 private readonly _mentions: NoteMention[]
39 private readonly _references: NoteReference[]
40 private readonly _hashtags: string[]
41
42 private constructor(
43 private readonly _event: Event,
44 mentions: NoteMention[],
45 references: NoteReference[],
46 hashtags: string[]
47 ) {
48 this._mentions = mentions
49 this._references = references
50 this._hashtags = hashtags
51 }
52
53 /**
54 * Create a Note from a Nostr Event
55 */
56 static fromEvent(event: Event): Note {
57 if (event.kind !== kinds.ShortTextNote) {
58 throw new Error(`Expected kind ${kinds.ShortTextNote}, got ${event.kind}`)
59 }
60
61 const mentions: NoteMention[] = []
62 const references: NoteReference[] = []
63 const hashtags: string[] = []
64
65 for (const tag of event.tags) {
66 if (tag[0] === 'p' && tag[1]) {
67 const pubkey = Pubkey.tryFromString(tag[1])
68 if (pubkey) {
69 mentions.push({
70 pubkey,
71 relayHint: tag[2] || undefined,
72 marker: tag[3] as NoteMention['marker']
73 })
74 }
75 } else if (tag[0] === 'e' && tag[1]) {
76 const eventId = EventId.tryFromString(tag[1])
77 if (eventId) {
78 const author = tag[4] ? Pubkey.tryFromString(tag[4]) : undefined
79 references.push({
80 eventId,
81 relayHint: tag[2] || undefined,
82 marker: tag[3] as NoteReference['marker'],
83 author: author || undefined
84 })
85 }
86 } else if (tag[0] === 't' && tag[1]) {
87 hashtags.push(tag[1].toLowerCase())
88 }
89 }
90
91 return new Note(event, mentions, references, hashtags)
92 }
93
94 /**
95 * Try to create a Note from an Event, returns null if invalid
96 */
97 static tryFromEvent(event: Event | null | undefined): Note | null {
98 if (!event) return null
99 try {
100 return Note.fromEvent(event)
101 } catch {
102 return null
103 }
104 }
105
106 /**
107 * The underlying Nostr event
108 */
109 get event(): Event {
110 return this._event
111 }
112
113 /**
114 * The note's event ID
115 */
116 get id(): EventId {
117 return EventId.fromHex(this._event.id)
118 }
119
120 /**
121 * The author's public key
122 */
123 get author(): Pubkey {
124 return Pubkey.fromHex(this._event.pubkey)
125 }
126
127 /**
128 * The note content
129 */
130 get content(): string {
131 return this._event.content
132 }
133
134 /**
135 * When the note was created
136 */
137 get createdAt(): Timestamp {
138 return Timestamp.fromUnix(this._event.created_at)
139 }
140
141 /**
142 * All mentioned users
143 */
144 get mentions(): NoteMention[] {
145 return [...this._mentions]
146 }
147
148 /**
149 * All referenced notes
150 */
151 get references(): NoteReference[] {
152 return [...this._references]
153 }
154
155 /**
156 * All hashtags in the note
157 */
158 get hashtags(): string[] {
159 return [...this._hashtags]
160 }
161
162 /**
163 * Get the type of note based on its references
164 */
165 get noteType(): NoteType {
166 const rootRef = this._references.find((r) => r.marker === 'root')
167 const replyRef = this._references.find((r) => r.marker === 'reply')
168 const quoteRef = this._event.tags.find((t) => t[0] === 'q')
169
170 if (rootRef || replyRef) {
171 return 'reply'
172 }
173 if (quoteRef) {
174 return 'quote'
175 }
176 return 'root'
177 }
178
179 /**
180 * Whether this is a root note (not a reply)
181 */
182 get isRoot(): boolean {
183 return this.noteType === 'root'
184 }
185
186 /**
187 * Whether this is a reply to another note
188 */
189 get isReply(): boolean {
190 return this.noteType === 'reply'
191 }
192
193 /**
194 * Get the root note reference (if this is a reply)
195 */
196 get rootReference(): NoteReference | undefined {
197 return this._references.find((r) => r.marker === 'root')
198 }
199
200 /**
201 * Get the parent note reference (if this is a reply)
202 */
203 get parentReference(): NoteReference | undefined {
204 return this._references.find((r) => r.marker === 'reply')
205 }
206
207 /**
208 * Whether this note has a content warning
209 */
210 get hasContentWarning(): boolean {
211 return this._event.tags.some((t) => t[0] === 'content-warning')
212 }
213
214 /**
215 * Get the content warning reason (if any)
216 */
217 get contentWarning(): string | undefined {
218 const tag = this._event.tags.find((t) => t[0] === 'content-warning')
219 return tag?.[1]
220 }
221
222 /**
223 * Whether this is an NSFW note
224 */
225 get isNsfw(): boolean {
226 const cwTag = this._event.tags.find((t) => t[0] === 'content-warning')
227 return cwTag?.[1]?.toLowerCase().includes('nsfw') ?? false
228 }
229
230 /**
231 * Whether this note mentions a specific user
232 */
233 mentionsUser(pubkey: Pubkey): boolean {
234 return this._mentions.some((m) => m.pubkey.equals(pubkey))
235 }
236
237 /**
238 * Whether this note references a specific event
239 */
240 referencesNote(eventId: EventId): boolean {
241 return this._references.some((r) => r.eventId.equals(eventId))
242 }
243
244 /**
245 * Whether this note includes a specific hashtag
246 */
247 hasHashtag(hashtag: string): boolean {
248 return this._hashtags.includes(hashtag.toLowerCase())
249 }
250
251 /**
252 * Get mentioned pubkeys as hex strings (for legacy compatibility)
253 */
254 getMentionedPubkeysHex(): string[] {
255 return this._mentions.map((m) => m.pubkey.hex)
256 }
257 }
258