FollowList.ts raw

   1  import { Event, kinds } from 'nostr-tools'
   2  import { Pubkey, Timestamp } from '../shared'
   3  import { CannotFollowSelfError } from './errors'
   4  
   5  /**
   6   * Represents a petname entry with relay hint
   7   */
   8  export type FollowEntry = {
   9    pubkey: Pubkey
  10    relayHint?: string
  11    petname?: string
  12  }
  13  
  14  /**
  15   * Result of a follow/unfollow operation
  16   */
  17  export type FollowListChange =
  18    | { type: 'added'; pubkey: Pubkey }
  19    | { type: 'removed'; pubkey: Pubkey }
  20    | { type: 'no_change' }
  21  
  22  /**
  23   * FollowList Aggregate
  24   *
  25   * Represents a user's contact list (kind 3 event in Nostr).
  26   * Encapsulates all business rules for following/unfollowing users.
  27   *
  28   * Invariants:
  29   * - Cannot follow self
  30   * - No duplicate entries
  31   * - Pubkeys must be valid
  32   */
  33  export class FollowList {
  34    private readonly _entries: Map<string, FollowEntry>
  35    private readonly _content: string
  36  
  37    private constructor(
  38      private readonly _owner: Pubkey,
  39      entries: FollowEntry[],
  40      content: string = ''
  41    ) {
  42      this._entries = new Map()
  43      for (const entry of entries) {
  44        this._entries.set(entry.pubkey.hex, entry)
  45      }
  46      this._content = content
  47    }
  48  
  49    /**
  50     * Create an empty FollowList for a user
  51     */
  52    static empty(owner: Pubkey): FollowList {
  53      return new FollowList(owner, [])
  54    }
  55  
  56    /**
  57     * Reconstruct a FollowList from a Nostr kind 3 event
  58     */
  59    static fromEvent(event: Event): FollowList {
  60      if (event.kind !== kinds.Contacts) {
  61        throw new Error(`Expected kind ${kinds.Contacts}, got ${event.kind}`)
  62      }
  63  
  64      const owner = Pubkey.fromHex(event.pubkey)
  65      const entries: FollowEntry[] = []
  66  
  67      for (const tag of event.tags) {
  68        if (tag[0] === 'p' && tag[1]) {
  69          const pubkey = Pubkey.tryFromString(tag[1])
  70          if (pubkey) {
  71            entries.push({
  72              pubkey,
  73              relayHint: tag[2] || undefined,
  74              petname: tag[3] || undefined
  75            })
  76          }
  77        }
  78      }
  79  
  80      return new FollowList(owner, entries, event.content)
  81    }
  82  
  83    /**
  84     * The owner of this follow list
  85     */
  86    get owner(): Pubkey {
  87      return this._owner
  88    }
  89  
  90    /**
  91     * Number of users being followed
  92     */
  93    get count(): number {
  94      return this._entries.size
  95    }
  96  
  97    /**
  98     * The raw content field (may contain relay preferences in legacy format)
  99     */
 100    get content(): string {
 101      return this._content
 102    }
 103  
 104    /**
 105     * Get all followed pubkeys
 106     */
 107    getFollowing(): Pubkey[] {
 108      return Array.from(this._entries.values()).map((e) => e.pubkey)
 109    }
 110  
 111    /**
 112     * Get all follow entries with metadata
 113     */
 114    getEntries(): FollowEntry[] {
 115      return Array.from(this._entries.values())
 116    }
 117  
 118    /**
 119     * Check if a user is being followed
 120     */
 121    isFollowing(pubkey: Pubkey): boolean {
 122      return this._entries.has(pubkey.hex)
 123    }
 124  
 125    /**
 126     * Get the entry for a followed user
 127     */
 128    getEntry(pubkey: Pubkey): FollowEntry | undefined {
 129      return this._entries.get(pubkey.hex)
 130    }
 131  
 132    /**
 133     * Follow a user
 134     *
 135     * @throws CannotFollowSelfError if attempting to follow self
 136     * @returns FollowListChange indicating what changed
 137     */
 138    follow(pubkey: Pubkey, relayHint?: string, petname?: string): FollowListChange {
 139      if (pubkey.equals(this._owner)) {
 140        throw new CannotFollowSelfError()
 141      }
 142  
 143      if (this._entries.has(pubkey.hex)) {
 144        return { type: 'no_change' }
 145      }
 146  
 147      this._entries.set(pubkey.hex, { pubkey, relayHint, petname })
 148      return { type: 'added', pubkey }
 149    }
 150  
 151    /**
 152     * Unfollow a user
 153     *
 154     * @returns FollowListChange indicating what changed
 155     */
 156    unfollow(pubkey: Pubkey): FollowListChange {
 157      if (!this._entries.has(pubkey.hex)) {
 158        return { type: 'no_change' }
 159      }
 160  
 161      this._entries.delete(pubkey.hex)
 162      return { type: 'removed', pubkey }
 163    }
 164  
 165    /**
 166     * Update petname for a followed user
 167     *
 168     * @returns true if updated, false if user not found
 169     */
 170    setPetname(pubkey: Pubkey, petname: string | undefined): boolean {
 171      const entry = this._entries.get(pubkey.hex)
 172      if (!entry) {
 173        return false
 174      }
 175  
 176      this._entries.set(pubkey.hex, { ...entry, petname })
 177      return true
 178    }
 179  
 180    /**
 181     * Convert to Nostr event tags format
 182     */
 183    toTags(): string[][] {
 184      return Array.from(this._entries.values()).map((entry) => {
 185        const tag = ['p', entry.pubkey.hex]
 186        if (entry.relayHint) {
 187          tag.push(entry.relayHint)
 188          if (entry.petname) {
 189            tag.push(entry.petname)
 190          }
 191        } else if (entry.petname) {
 192          tag.push('', entry.petname)
 193        }
 194        return tag
 195      })
 196    }
 197  
 198    /**
 199     * Convert to a draft event for publishing
 200     */
 201    toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
 202      return {
 203        kind: kinds.Contacts,
 204        content: this._content,
 205        created_at: Timestamp.now().unix,
 206        tags: this.toTags()
 207      }
 208    }
 209  
 210    /**
 211     * Create a new FollowList with the same entries but different owner
 212     * Useful for importing someone else's follow list
 213     */
 214    cloneFor(newOwner: Pubkey): FollowList {
 215      const entries = this.getEntries().filter((e) => !e.pubkey.equals(newOwner))
 216      return new FollowList(newOwner, entries, this._content)
 217    }
 218  
 219    /**
 220     * Merge another follow list into this one (union of both)
 221     */
 222    merge(other: FollowList): void {
 223      for (const entry of other.getEntries()) {
 224        if (!entry.pubkey.equals(this._owner) && !this._entries.has(entry.pubkey.hex)) {
 225          this._entries.set(entry.pubkey.hex, entry)
 226        }
 227      }
 228    }
 229  }
 230