PinnedUsersList.ts raw

   1  import { Event } from 'nostr-tools'
   2  import { ExtendedKind } from '@/constants'
   3  import { Pubkey, Timestamp } from '../shared'
   4  
   5  /**
   6   * Represents a pinned user entry
   7   */
   8  export type PinnedUserEntry = {
   9    pubkey: Pubkey
  10    isPrivate: boolean
  11  }
  12  
  13  /**
  14   * Result of a pin/unpin operation
  15   */
  16  export type PinnedUsersListChange =
  17    | { type: 'pinned'; pubkey: Pubkey }
  18    | { type: 'unpinned'; pubkey: Pubkey }
  19    | { type: 'no_change' }
  20  
  21  /**
  22   * PinnedUsersList Aggregate
  23   *
  24   * Represents a user's pinned users list (kind 10003 in Nostr).
  25   * Supports both public (in tags) and private (encrypted content) pins.
  26   *
  27   * Invariants:
  28   * - Cannot pin self
  29   * - No duplicate entries
  30   * - Pubkeys must be valid
  31   */
  32  export class PinnedUsersList {
  33    private readonly _publicPins: Map<string, PinnedUserEntry>
  34    private readonly _privatePins: Map<string, PinnedUserEntry>
  35    private _encryptedContent: string
  36  
  37    private constructor(
  38      private readonly _owner: Pubkey,
  39      publicPins: PinnedUserEntry[],
  40      privatePins: PinnedUserEntry[],
  41      encryptedContent: string = ''
  42    ) {
  43      this._publicPins = new Map()
  44      this._privatePins = new Map()
  45      this._encryptedContent = encryptedContent
  46  
  47      for (const pin of publicPins) {
  48        this._publicPins.set(pin.pubkey.hex, pin)
  49      }
  50      for (const pin of privatePins) {
  51        this._privatePins.set(pin.pubkey.hex, pin)
  52      }
  53    }
  54  
  55    /**
  56     * Create an empty PinnedUsersList for a user
  57     */
  58    static empty(owner: Pubkey): PinnedUsersList {
  59      return new PinnedUsersList(owner, [], [])
  60    }
  61  
  62    /**
  63     * Reconstruct a PinnedUsersList from a Nostr event (public pins only)
  64     * Private pins must be added separately after decryption
  65     */
  66    static fromEvent(event: Event): PinnedUsersList {
  67      if (event.kind !== ExtendedKind.PINNED_USERS) {
  68        throw new Error(`Expected kind ${ExtendedKind.PINNED_USERS}, got ${event.kind}`)
  69      }
  70  
  71      const owner = Pubkey.fromHex(event.pubkey)
  72      const publicPins: PinnedUserEntry[] = []
  73  
  74      for (const tag of event.tags) {
  75        if (tag[0] === 'p' && tag[1]) {
  76          const pubkey = Pubkey.tryFromString(tag[1])
  77          if (pubkey) {
  78            publicPins.push({ pubkey, isPrivate: false })
  79          }
  80        }
  81      }
  82  
  83      return new PinnedUsersList(owner, publicPins, [], event.content)
  84    }
  85  
  86    /**
  87     * The owner of this pinned users list
  88     */
  89    get owner(): Pubkey {
  90      return this._owner
  91    }
  92  
  93    /**
  94     * Total number of pinned users (public + private)
  95     */
  96    get count(): number {
  97      return this._publicPins.size + this._privatePins.size
  98    }
  99  
 100    /**
 101     * Number of public pins
 102     */
 103    get publicCount(): number {
 104      return this._publicPins.size
 105    }
 106  
 107    /**
 108     * Number of private pins
 109     */
 110    get privateCount(): number {
 111      return this._privatePins.size
 112    }
 113  
 114    /**
 115     * The encrypted content (private pins)
 116     */
 117    get encryptedContent(): string {
 118      return this._encryptedContent
 119    }
 120  
 121    /**
 122     * Set decrypted private pins
 123     */
 124    setPrivatePins(privateTags: string[][]): void {
 125      this._privatePins.clear()
 126      for (const tag of privateTags) {
 127        if (tag[0] === 'p' && tag[1]) {
 128          const pubkey = Pubkey.tryFromString(tag[1])
 129          if (pubkey) {
 130            this._privatePins.set(pubkey.hex, { pubkey, isPrivate: true })
 131          }
 132        }
 133      }
 134    }
 135  
 136    /**
 137     * Get all pinned pubkeys
 138     */
 139    getPinnedPubkeys(): Pubkey[] {
 140      const all = new Map(this._publicPins)
 141      for (const [hex, entry] of this._privatePins) {
 142        all.set(hex, entry)
 143      }
 144      return Array.from(all.values()).map((e) => e.pubkey)
 145    }
 146  
 147    /**
 148     * Get all pinned entries
 149     */
 150    getEntries(): PinnedUserEntry[] {
 151      const all = new Map(this._publicPins)
 152      for (const [hex, entry] of this._privatePins) {
 153        all.set(hex, entry)
 154      }
 155      return Array.from(all.values())
 156    }
 157  
 158    /**
 159     * Get public entries only
 160     */
 161    getPublicEntries(): PinnedUserEntry[] {
 162      return Array.from(this._publicPins.values())
 163    }
 164  
 165    /**
 166     * Get private entries only
 167     */
 168    getPrivateEntries(): PinnedUserEntry[] {
 169      return Array.from(this._privatePins.values())
 170    }
 171  
 172    /**
 173     * Check if a user is pinned
 174     */
 175    isPinned(pubkey: Pubkey): boolean {
 176      return this._publicPins.has(pubkey.hex) || this._privatePins.has(pubkey.hex)
 177    }
 178  
 179    /**
 180     * Pin a user publicly
 181     *
 182     * @throws Error if attempting to pin self
 183     * @returns PinnedUsersListChange indicating what changed
 184     */
 185    pin(pubkey: Pubkey): PinnedUsersListChange {
 186      if (pubkey.equals(this._owner)) {
 187        throw new Error('Cannot pin self')
 188      }
 189  
 190      if (this.isPinned(pubkey)) {
 191        return { type: 'no_change' }
 192      }
 193  
 194      this._publicPins.set(pubkey.hex, { pubkey, isPrivate: false })
 195      return { type: 'pinned', pubkey }
 196    }
 197  
 198    /**
 199     * Unpin a user
 200     *
 201     * @returns PinnedUsersListChange indicating what changed
 202     */
 203    unpin(pubkey: Pubkey): PinnedUsersListChange {
 204      const wasPublic = this._publicPins.delete(pubkey.hex)
 205      const wasPrivate = this._privatePins.delete(pubkey.hex)
 206  
 207      if (wasPublic || wasPrivate) {
 208        return { type: 'unpinned', pubkey }
 209      }
 210  
 211      return { type: 'no_change' }
 212    }
 213  
 214    /**
 215     * Convert public pins to Nostr event tags format
 216     */
 217    toTags(): string[][] {
 218      return Array.from(this._publicPins.values()).map((entry) => ['p', entry.pubkey.hex])
 219    }
 220  
 221    /**
 222     * Convert private pins to tags for encryption
 223     */
 224    toPrivateTags(): string[][] {
 225      return Array.from(this._privatePins.values()).map((entry) => ['p', entry.pubkey.hex])
 226    }
 227  
 228    /**
 229     * Set encrypted content (after encrypting private tags)
 230     */
 231    setEncryptedContent(content: string): void {
 232      this._encryptedContent = content
 233    }
 234  
 235    /**
 236     * Convert to a draft event for publishing
 237     */
 238    toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
 239      return {
 240        kind: ExtendedKind.PINNED_USERS,
 241        content: this._encryptedContent,
 242        created_at: Timestamp.now().unix,
 243        tags: this.toTags()
 244      }
 245    }
 246  }
 247  
 248  /**
 249   * Try to create a PinnedUsersList from an event
 250   * Returns null if the event is not a valid pinned users event
 251   */
 252  export function tryToPinnedUsersList(event: Event | null | undefined): PinnedUsersList | null {
 253    if (!event || event.kind !== ExtendedKind.PINNED_USERS) {
 254      return null
 255    }
 256    try {
 257      return PinnedUsersList.fromEvent(event)
 258    } catch {
 259      return null
 260    }
 261  }
 262