RelaySet.ts raw

   1  import { Event, kinds } from 'nostr-tools'
   2  import { RelayUrl, Timestamp } from '../shared'
   3  
   4  /**
   5   * Result of a relay set modification
   6   */
   7  export type RelaySetChange =
   8    | { type: 'added'; relay: RelayUrl }
   9    | { type: 'removed'; relay: RelayUrl }
  10    | { type: 'no_change' }
  11  
  12  /**
  13   * RelaySet Aggregate
  14   *
  15   * Represents a named collection of relays (kind 30002 in Nostr).
  16   * Used for organizing relays into groups like "fast relays", "paid relays", etc.
  17   *
  18   * Invariants:
  19   * - Name is required and non-empty
  20   * - No duplicate relay URLs
  21   * - All URLs must be valid WebSocket URLs
  22   */
  23  export class RelaySet {
  24    private readonly _relays: Map<string, RelayUrl>
  25  
  26    private constructor(
  27      private readonly _id: string,
  28      private _name: string,
  29      relays: RelayUrl[]
  30    ) {
  31      this._relays = new Map()
  32      for (const relay of relays) {
  33        this._relays.set(relay.value, relay)
  34      }
  35    }
  36  
  37    /**
  38     * Create a new empty RelaySet with a generated ID
  39     */
  40    static create(name: string, id?: string): RelaySet {
  41      const setId = id || crypto.randomUUID().replace(/-/g, '').slice(0, 12)
  42      return new RelaySet(setId, name.trim() || 'Unnamed Set', [])
  43    }
  44  
  45    /**
  46     * Create a RelaySet with initial relays
  47     */
  48    static createWithRelays(name: string, relayUrls: string[], id?: string): RelaySet {
  49      const set = RelaySet.create(name, id)
  50      for (const url of relayUrls) {
  51        const relay = RelayUrl.tryCreate(url)
  52        if (relay) {
  53          set._relays.set(relay.value, relay)
  54        }
  55      }
  56      return set
  57    }
  58  
  59    /**
  60     * Reconstruct a RelaySet from a Nostr kind 30002 event
  61     */
  62    static fromEvent(event: Event): RelaySet {
  63      if (event.kind !== kinds.Relaysets) {
  64        throw new Error(`Expected kind ${kinds.Relaysets}, got ${event.kind}`)
  65      }
  66  
  67      let id = ''
  68      let name = ''
  69      const relays: RelayUrl[] = []
  70  
  71      for (const tag of event.tags) {
  72        if (tag[0] === 'd' && tag[1]) {
  73          id = tag[1]
  74        } else if (tag[0] === 'title' && tag[1]) {
  75          name = tag[1]
  76        } else if (tag[0] === 'relay' && tag[1]) {
  77          const relay = RelayUrl.tryCreate(tag[1])
  78          if (relay) {
  79            relays.push(relay)
  80          }
  81        }
  82      }
  83  
  84      return new RelaySet(id || 'unknown', name || 'Unnamed Set', relays)
  85    }
  86  
  87    /**
  88     * The unique identifier for this relay set
  89     */
  90    get id(): string {
  91      return this._id
  92    }
  93  
  94    /**
  95     * The display name of this relay set
  96     */
  97    get name(): string {
  98      return this._name
  99    }
 100  
 101    /**
 102     * Number of relays in this set
 103     */
 104    get count(): number {
 105      return this._relays.size
 106    }
 107  
 108    /**
 109     * Check if the set is empty
 110     */
 111    get isEmpty(): boolean {
 112      return this._relays.size === 0
 113    }
 114  
 115    /**
 116     * Get all relays in this set
 117     */
 118    getRelays(): RelayUrl[] {
 119      return Array.from(this._relays.values())
 120    }
 121  
 122    /**
 123     * Get all relay URLs as strings
 124     */
 125    getRelayUrls(): string[] {
 126      return Array.from(this._relays.keys())
 127    }
 128  
 129    /**
 130     * Check if a relay is in this set
 131     */
 132    hasRelay(relay: RelayUrl): boolean {
 133      return this._relays.has(relay.value)
 134    }
 135  
 136    /**
 137     * Check if a relay URL string is in this set
 138     */
 139    hasRelayUrl(url: string): boolean {
 140      const relay = RelayUrl.tryCreate(url)
 141      return relay ? this._relays.has(relay.value) : false
 142    }
 143  
 144    /**
 145     * Rename this relay set
 146     */
 147    rename(newName: string): void {
 148      this._name = newName.trim() || 'Unnamed Set'
 149    }
 150  
 151    /**
 152     * Add a relay to this set
 153     *
 154     * @returns RelaySetChange indicating what changed
 155     */
 156    addRelay(relay: RelayUrl): RelaySetChange {
 157      if (this._relays.has(relay.value)) {
 158        return { type: 'no_change' }
 159      }
 160  
 161      this._relays.set(relay.value, relay)
 162      return { type: 'added', relay }
 163    }
 164  
 165    /**
 166     * Add a relay by URL string
 167     *
 168     * @returns RelaySetChange or null if URL is invalid
 169     */
 170    addRelayUrl(url: string): RelaySetChange | null {
 171      const relay = RelayUrl.tryCreate(url)
 172      if (!relay) return null
 173      return this.addRelay(relay)
 174    }
 175  
 176    /**
 177     * Remove a relay from this set
 178     *
 179     * @returns RelaySetChange indicating what changed
 180     */
 181    removeRelay(relay: RelayUrl): RelaySetChange {
 182      if (!this._relays.has(relay.value)) {
 183        return { type: 'no_change' }
 184      }
 185  
 186      this._relays.delete(relay.value)
 187      return { type: 'removed', relay }
 188    }
 189  
 190    /**
 191     * Remove a relay by URL string
 192     *
 193     * @returns RelaySetChange or null if URL is invalid
 194     */
 195    removeRelayUrl(url: string): RelaySetChange | null {
 196      const relay = RelayUrl.tryCreate(url)
 197      if (!relay) return null
 198      return this.removeRelay(relay)
 199    }
 200  
 201    /**
 202     * Replace all relays with a new list
 203     */
 204    setRelays(relays: RelayUrl[]): void {
 205      this._relays.clear()
 206      for (const relay of relays) {
 207        this._relays.set(relay.value, relay)
 208      }
 209    }
 210  
 211    /**
 212     * Convert to the 'a' tag reference format
 213     */
 214    toATag(pubkey: string): string[] {
 215      return ['a', `${kinds.Relaysets}:${pubkey}:${this._id}`]
 216    }
 217  
 218    /**
 219     * Convert to Nostr event tags format
 220     */
 221    toTags(): string[][] {
 222      const tags: string[][] = [
 223        ['d', this._id],
 224        ['title', this._name]
 225      ]
 226  
 227      for (const relay of this._relays.values()) {
 228        tags.push(['relay', relay.value])
 229      }
 230  
 231      return tags
 232    }
 233  
 234    /**
 235     * Convert to a draft event for publishing
 236     */
 237    toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
 238      return {
 239        kind: kinds.Relaysets,
 240        content: '',
 241        created_at: Timestamp.now().unix,
 242        tags: this.toTags()
 243      }
 244    }
 245  }
 246