BookmarkList.ts raw
1 import { Event, kinds } from 'nostr-tools'
2 import { EventId, Pubkey, Timestamp } from '../shared'
3
4 /**
5 * Type of bookmarked item
6 */
7 export type BookmarkType = 'event' | 'replaceable'
8
9 /**
10 * A bookmarked item
11 */
12 export type BookmarkEntry = {
13 type: BookmarkType
14 id: string // event id or 'a' tag coordinate
15 pubkey?: Pubkey
16 relayHint?: string
17 }
18
19 /**
20 * Result of a bookmark operation
21 */
22 export type BookmarkListChange =
23 | { type: 'added'; entry: BookmarkEntry }
24 | { type: 'removed'; id: string }
25 | { type: 'no_change' }
26
27 /**
28 * BookmarkList Aggregate
29 *
30 * Represents a user's bookmark list (kind 10003 in Nostr).
31 * Supports both regular events (e tags) and replaceable events (a tags).
32 *
33 * Invariants:
34 * - No duplicate entries
35 * - Event IDs and coordinates must be valid
36 */
37 export class BookmarkList {
38 private readonly _entries: Map<string, BookmarkEntry>
39 private readonly _content: string
40
41 private constructor(
42 private readonly _owner: Pubkey,
43 entries: BookmarkEntry[],
44 content: string = ''
45 ) {
46 this._entries = new Map()
47 for (const entry of entries) {
48 this._entries.set(entry.id, entry)
49 }
50 this._content = content
51 }
52
53 /**
54 * Create an empty BookmarkList for a user
55 */
56 static empty(owner: Pubkey): BookmarkList {
57 return new BookmarkList(owner, [])
58 }
59
60 /**
61 * Reconstruct a BookmarkList from a Nostr kind 10003 event
62 */
63 static fromEvent(event: Event): BookmarkList {
64 if (event.kind !== kinds.BookmarkList) {
65 throw new Error(`Expected kind ${kinds.BookmarkList}, got ${event.kind}`)
66 }
67
68 const owner = Pubkey.fromHex(event.pubkey)
69 const entries: BookmarkEntry[] = []
70
71 for (const tag of event.tags) {
72 if (tag[0] === 'e' && tag[1]) {
73 const eventId = EventId.tryFromString(tag[1])
74 if (eventId) {
75 const pubkey = tag[2] ? Pubkey.tryFromString(tag[2]) : undefined
76 entries.push({
77 type: 'event',
78 id: eventId.hex,
79 pubkey: pubkey || undefined,
80 relayHint: tag[3] || undefined
81 })
82 }
83 } else if (tag[0] === 'a' && tag[1]) {
84 entries.push({
85 type: 'replaceable',
86 id: tag[1],
87 relayHint: tag[2] || undefined
88 })
89 }
90 }
91
92 return new BookmarkList(owner, entries, event.content)
93 }
94
95 /**
96 * Try to create a BookmarkList from an event, returns null if invalid
97 */
98 static tryFromEvent(event: Event | null | undefined): BookmarkList | null {
99 if (!event) return null
100 try {
101 return BookmarkList.fromEvent(event)
102 } catch {
103 return null
104 }
105 }
106
107 /**
108 * The owner of this bookmark list
109 */
110 get owner(): Pubkey {
111 return this._owner
112 }
113
114 /**
115 * Number of bookmarked items
116 */
117 get count(): number {
118 return this._entries.size
119 }
120
121 /**
122 * The raw content field
123 */
124 get content(): string {
125 return this._content
126 }
127
128 /**
129 * Get all bookmark entries
130 */
131 getEntries(): BookmarkEntry[] {
132 return Array.from(this._entries.values())
133 }
134
135 /**
136 * Get all bookmarked event IDs (e tags only)
137 */
138 getEventIds(): string[] {
139 return Array.from(this._entries.values())
140 .filter((e) => e.type === 'event')
141 .map((e) => e.id)
142 }
143
144 /**
145 * Get all bookmarked replaceable coordinates (a tags only)
146 */
147 getReplaceableCoordinates(): string[] {
148 return Array.from(this._entries.values())
149 .filter((e) => e.type === 'replaceable')
150 .map((e) => e.id)
151 }
152
153 /**
154 * Check if an item is bookmarked by event ID
155 */
156 hasEventId(eventId: string): boolean {
157 return this._entries.has(eventId)
158 }
159
160 /**
161 * Check if a replaceable event is bookmarked by coordinate
162 */
163 hasCoordinate(coordinate: string): boolean {
164 return this._entries.has(coordinate)
165 }
166
167 /**
168 * Check if any form of the item is bookmarked
169 */
170 isBookmarked(idOrCoordinate: string): boolean {
171 return this._entries.has(idOrCoordinate)
172 }
173
174 /**
175 * Add an event bookmark
176 *
177 * @returns BookmarkListChange indicating what changed
178 */
179 addEvent(eventId: EventId, pubkey?: Pubkey, relayHint?: string): BookmarkListChange {
180 const id = eventId.hex
181
182 if (this._entries.has(id)) {
183 return { type: 'no_change' }
184 }
185
186 const entry: BookmarkEntry = {
187 type: 'event',
188 id,
189 pubkey,
190 relayHint
191 }
192 this._entries.set(id, entry)
193 return { type: 'added', entry }
194 }
195
196 /**
197 * Add a replaceable event bookmark by coordinate
198 *
199 * @param coordinate The 'a' tag coordinate (kind:pubkey:d-tag)
200 * @returns BookmarkListChange indicating what changed
201 */
202 addReplaceable(coordinate: string, relayHint?: string): BookmarkListChange {
203 if (this._entries.has(coordinate)) {
204 return { type: 'no_change' }
205 }
206
207 const entry: BookmarkEntry = {
208 type: 'replaceable',
209 id: coordinate,
210 relayHint
211 }
212 this._entries.set(coordinate, entry)
213 return { type: 'added', entry }
214 }
215
216 /**
217 * Add a bookmark from a Nostr event
218 *
219 * @returns BookmarkListChange indicating what changed
220 */
221 addFromEvent(event: Event): BookmarkListChange {
222 // Check if replaceable event
223 if (this.isReplaceableKind(event.kind)) {
224 const dTag = event.tags.find((t) => t[0] === 'd')?.[1] || ''
225 const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
226 return this.addReplaceable(coordinate)
227 }
228
229 // Regular event
230 const eventId = EventId.tryFromString(event.id)
231 if (!eventId) return { type: 'no_change' }
232
233 const pubkey = Pubkey.tryFromString(event.pubkey)
234 return this.addEvent(eventId, pubkey || undefined)
235 }
236
237 /**
238 * Remove a bookmark by ID or coordinate
239 *
240 * @returns BookmarkListChange indicating what changed
241 */
242 remove(idOrCoordinate: string): BookmarkListChange {
243 if (!this._entries.has(idOrCoordinate)) {
244 return { type: 'no_change' }
245 }
246
247 this._entries.delete(idOrCoordinate)
248 return { type: 'removed', id: idOrCoordinate }
249 }
250
251 /**
252 * Remove a bookmark by event
253 */
254 removeFromEvent(event: Event): BookmarkListChange {
255 // Check if replaceable event
256 if (this.isReplaceableKind(event.kind)) {
257 const dTag = event.tags.find((t) => t[0] === 'd')?.[1] || ''
258 const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
259 return this.remove(coordinate)
260 }
261
262 return this.remove(event.id)
263 }
264
265 /**
266 * Check if a kind is replaceable
267 */
268 private isReplaceableKind(kind: number): boolean {
269 return (kind >= 10000 && kind < 20000) || (kind >= 30000 && kind < 40000)
270 }
271
272 /**
273 * Convert to Nostr event tags format
274 */
275 toTags(): string[][] {
276 const tags: string[][] = []
277
278 for (const entry of this._entries.values()) {
279 if (entry.type === 'event') {
280 const tag = ['e', entry.id]
281 if (entry.pubkey) {
282 tag.push(entry.pubkey.hex)
283 if (entry.relayHint) {
284 tag.push(entry.relayHint)
285 }
286 } else if (entry.relayHint) {
287 tag.push('', entry.relayHint)
288 }
289 tags.push(tag)
290 } else {
291 const tag = ['a', entry.id]
292 if (entry.relayHint) {
293 tag.push(entry.relayHint)
294 }
295 tags.push(tag)
296 }
297 }
298
299 return tags
300 }
301
302 /**
303 * Convert to a draft event for publishing
304 */
305 toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
306 return {
307 kind: kinds.BookmarkList,
308 content: this._content,
309 created_at: Timestamp.now().unix,
310 tags: this.toTags()
311 }
312 }
313 }
314