PinList.ts raw

   1  import { Event, kinds } from 'nostr-tools'
   2  import { EventId, Pubkey, Timestamp } from '../shared'
   3  
   4  /**
   5   * Maximum number of pinned notes allowed
   6   */
   7  export const MAX_PINNED_NOTES = 5
   8  
   9  /**
  10   * A pinned note entry
  11   */
  12  export type PinEntry = {
  13    eventId: EventId
  14    pubkey?: Pubkey
  15    relayHint?: string
  16  }
  17  
  18  /**
  19   * Result of a pin operation
  20   */
  21  export type PinListChange =
  22    | { type: 'pinned'; entry: PinEntry }
  23    | { type: 'unpinned'; eventId: string }
  24    | { type: 'no_change' }
  25    | { type: 'limit_exceeded'; removed: PinEntry[] }
  26  
  27  /**
  28   * Error thrown when trying to pin non-own content
  29   */
  30  export class CannotPinOthersContentError extends Error {
  31    constructor() {
  32      super('Cannot pin content from other users')
  33      this.name = 'CannotPinOthersContentError'
  34    }
  35  }
  36  
  37  /**
  38   * Error thrown when trying to pin non-note content
  39   */
  40  export class CanOnlyPinNotesError extends Error {
  41    constructor() {
  42      super('Can only pin short text notes')
  43      this.name = 'CanOnlyPinNotesError'
  44    }
  45  }
  46  
  47  /**
  48   * PinList Aggregate
  49   *
  50   * Represents a user's pinned notes list (kind 10001 in Nostr).
  51   * Users can pin their own short text notes to highlight them on their profile.
  52   *
  53   * Invariants:
  54   * - Can only pin own notes (same pubkey)
  55   * - Can only pin short text notes (kind 1)
  56   * - Maximum of MAX_PINNED_NOTES entries (oldest removed when exceeded)
  57   * - No duplicate entries
  58   */
  59  export class PinList {
  60    private readonly _entries: Map<string, PinEntry>
  61    private readonly _order: string[] // Maintains insertion order
  62    private readonly _content: string
  63  
  64    private constructor(
  65      private readonly _owner: Pubkey,
  66      entries: PinEntry[],
  67      content: string = ''
  68    ) {
  69      this._entries = new Map()
  70      this._order = []
  71      for (const entry of entries) {
  72        this._entries.set(entry.eventId.hex, entry)
  73        this._order.push(entry.eventId.hex)
  74      }
  75      this._content = content
  76    }
  77  
  78    /**
  79     * Create an empty PinList for a user
  80     */
  81    static empty(owner: Pubkey): PinList {
  82      return new PinList(owner, [])
  83    }
  84  
  85    /**
  86     * Reconstruct a PinList from a Nostr kind 10001 event
  87     */
  88    static fromEvent(event: Event): PinList {
  89      if (event.kind !== kinds.Pinlist) {
  90        throw new Error(`Expected kind ${kinds.Pinlist}, got ${event.kind}`)
  91      }
  92  
  93      const owner = Pubkey.fromHex(event.pubkey)
  94      const entries: PinEntry[] = []
  95  
  96      for (const tag of event.tags) {
  97        if (tag[0] === 'e' && tag[1]) {
  98          const eventId = EventId.tryFromString(tag[1])
  99          if (eventId && !entries.some((e) => e.eventId.hex === eventId.hex)) {
 100            const pubkey = tag[2] ? Pubkey.tryFromString(tag[2]) : undefined
 101            entries.push({
 102              eventId,
 103              pubkey: pubkey || undefined,
 104              relayHint: tag[3] || undefined
 105            })
 106          }
 107        }
 108      }
 109  
 110      return new PinList(owner, entries, event.content)
 111    }
 112  
 113    /**
 114     * Try to create a PinList from an event, returns null if invalid
 115     */
 116    static tryFromEvent(event: Event | null | undefined): PinList | null {
 117      if (!event) return null
 118      try {
 119        return PinList.fromEvent(event)
 120      } catch {
 121        return null
 122      }
 123    }
 124  
 125    /**
 126     * The owner of this pin list
 127     */
 128    get owner(): Pubkey {
 129      return this._owner
 130    }
 131  
 132    /**
 133     * Number of pinned notes
 134     */
 135    get count(): number {
 136      return this._entries.size
 137    }
 138  
 139    /**
 140     * Whether the pin list is at maximum capacity
 141     */
 142    get isFull(): boolean {
 143      return this._entries.size >= MAX_PINNED_NOTES
 144    }
 145  
 146    /**
 147     * The raw content field
 148     */
 149    get content(): string {
 150      return this._content
 151    }
 152  
 153    /**
 154     * Get all pinned entries in order
 155     */
 156    getEntries(): PinEntry[] {
 157      return this._order.map((id) => this._entries.get(id)!).filter(Boolean)
 158    }
 159  
 160    /**
 161     * Get all pinned event IDs
 162     */
 163    getEventIds(): string[] {
 164      return [...this._order]
 165    }
 166  
 167    /**
 168     * Get pinned event IDs as a Set for fast lookup
 169     */
 170    getEventIdSet(): Set<string> {
 171      return new Set(this._order)
 172    }
 173  
 174    /**
 175     * Check if a note is pinned
 176     */
 177    isPinned(eventId: string): boolean {
 178      return this._entries.has(eventId)
 179    }
 180  
 181    /**
 182     * Pin a note
 183     *
 184     * @throws CannotPinOthersContentError if note is from another user
 185     * @throws CanOnlyPinNotesError if event is not a short text note
 186     * @returns PinListChange indicating what changed
 187     */
 188    pin(event: Event): PinListChange {
 189      // Validate: only own notes
 190      if (event.pubkey !== this._owner.hex) {
 191        throw new CannotPinOthersContentError()
 192      }
 193  
 194      // Validate: only short text notes
 195      if (event.kind !== kinds.ShortTextNote) {
 196        throw new CanOnlyPinNotesError()
 197      }
 198  
 199      const eventId = EventId.fromHex(event.id)
 200  
 201      // Check for duplicate
 202      if (this._entries.has(eventId.hex)) {
 203        return { type: 'no_change' }
 204      }
 205  
 206      const entry: PinEntry = {
 207        eventId,
 208        pubkey: this._owner,
 209        relayHint: undefined
 210      }
 211  
 212      // Check capacity and remove oldest if needed
 213      const removed: PinEntry[] = []
 214      while (this._entries.size >= MAX_PINNED_NOTES) {
 215        const oldestId = this._order.shift()
 216        if (oldestId) {
 217          const oldEntry = this._entries.get(oldestId)
 218          if (oldEntry) {
 219            removed.push(oldEntry)
 220          }
 221          this._entries.delete(oldestId)
 222        }
 223      }
 224  
 225      // Add new pin
 226      this._entries.set(eventId.hex, entry)
 227      this._order.push(eventId.hex)
 228  
 229      if (removed.length > 0) {
 230        return { type: 'limit_exceeded', removed }
 231      }
 232  
 233      return { type: 'pinned', entry }
 234    }
 235  
 236    /**
 237     * Unpin a note
 238     *
 239     * @returns PinListChange indicating what changed
 240     */
 241    unpin(eventId: string): PinListChange {
 242      if (!this._entries.has(eventId)) {
 243        return { type: 'no_change' }
 244      }
 245  
 246      this._entries.delete(eventId)
 247      const index = this._order.indexOf(eventId)
 248      if (index !== -1) {
 249        this._order.splice(index, 1)
 250      }
 251  
 252      return { type: 'unpinned', eventId }
 253    }
 254  
 255    /**
 256     * Unpin by event
 257     */
 258    unpinEvent(event: Event): PinListChange {
 259      return this.unpin(event.id)
 260    }
 261  
 262    /**
 263     * Convert to Nostr event tags format
 264     */
 265    toTags(): string[][] {
 266      const tags: string[][] = []
 267  
 268      for (const id of this._order) {
 269        const entry = this._entries.get(id)
 270        if (entry) {
 271          const tag = ['e', entry.eventId.hex]
 272          if (entry.pubkey) {
 273            tag.push(entry.pubkey.hex)
 274            if (entry.relayHint) {
 275              tag.push(entry.relayHint)
 276            }
 277          } else if (entry.relayHint) {
 278            tag.push('', entry.relayHint)
 279          }
 280          tags.push(tag)
 281        }
 282      }
 283  
 284      return tags
 285    }
 286  
 287    /**
 288     * Convert to a draft event for publishing
 289     */
 290    toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
 291      return {
 292        kind: kinds.Pinlist,
 293        content: this._content,
 294        created_at: Timestamp.now().unix,
 295        tags: this.toTags()
 296      }
 297    }
 298  }
 299