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