RelayList.ts raw

   1  import { Event, kinds } from 'nostr-tools'
   2  import { Pubkey, RelayUrl, Timestamp } from '../shared'
   3  
   4  /**
   5   * The scope of a relay in a relay list (read, write, or both)
   6   */
   7  export type RelayScope = 'read' | 'write' | 'both'
   8  
   9  /**
  10   * A relay entry with its scope
  11   */
  12  export type RelayEntry = {
  13    relay: RelayUrl
  14    scope: RelayScope
  15  }
  16  
  17  /**
  18   * Result of a relay list modification
  19   */
  20  export type RelayListChange =
  21    | { type: 'added'; relay: RelayUrl; scope: RelayScope }
  22    | { type: 'removed'; relay: RelayUrl }
  23    | { type: 'scope_changed'; relay: RelayUrl; from: RelayScope; to: RelayScope }
  24    | { type: 'no_change' }
  25  
  26  /**
  27   * RelayList Aggregate
  28   *
  29   * Represents a user's mailbox relay preferences (kind 10002 in Nostr, NIP-65).
  30   * Defines which relays the user reads from and writes to.
  31   *
  32   * Invariants:
  33   * - No duplicate relay URLs
  34   * - All URLs must be valid WebSocket URLs
  35   * - At least one read and one write relay is recommended (but not enforced)
  36   */
  37  export class RelayList {
  38    private readonly _relays: Map<string, RelayEntry>
  39  
  40    private constructor(
  41      private readonly _owner: Pubkey,
  42      entries: RelayEntry[]
  43    ) {
  44      this._relays = new Map()
  45      for (const entry of entries) {
  46        this._relays.set(entry.relay.value, entry)
  47      }
  48    }
  49  
  50    /**
  51     * Create an empty RelayList for a user
  52     */
  53    static empty(owner: Pubkey): RelayList {
  54      return new RelayList(owner, [])
  55    }
  56  
  57    /**
  58     * Create a RelayList with initial relays (all set to 'both')
  59     */
  60    static fromUrls(owner: Pubkey, urls: string[]): RelayList {
  61      const entries: RelayEntry[] = []
  62      for (const url of urls) {
  63        const relay = RelayUrl.tryCreate(url)
  64        if (relay) {
  65          entries.push({ relay, scope: 'both' })
  66        }
  67      }
  68      return new RelayList(owner, entries)
  69    }
  70  
  71    /**
  72     * Reconstruct a RelayList from a Nostr kind 10002 event
  73     *
  74     * @param event The relay list event
  75     * @param filterOutOnion Whether to filter out .onion addresses
  76     */
  77    static fromEvent(event: Event, filterOutOnion = false): RelayList {
  78      if (event.kind !== kinds.RelayList) {
  79        throw new Error(`Expected kind ${kinds.RelayList}, got ${event.kind}`)
  80      }
  81  
  82      const owner = Pubkey.fromHex(event.pubkey)
  83      const entries: RelayEntry[] = []
  84  
  85      for (const tag of event.tags) {
  86        if (tag[0] === 'r' && tag[1]) {
  87          const relay = RelayUrl.tryCreate(tag[1])
  88          if (!relay) continue
  89          if (filterOutOnion && relay.isOnion) continue
  90  
  91          let scope: RelayScope = 'both'
  92          if (tag[2] === 'read') {
  93            scope = 'read'
  94          } else if (tag[2] === 'write') {
  95            scope = 'write'
  96          }
  97  
  98          entries.push({ relay, scope })
  99        }
 100      }
 101  
 102      return new RelayList(owner, entries)
 103    }
 104  
 105    /**
 106     * The owner of this relay list
 107     */
 108    get owner(): Pubkey {
 109      return this._owner
 110    }
 111  
 112    /**
 113     * Total number of relays
 114     */
 115    get count(): number {
 116      return this._relays.size
 117    }
 118  
 119    /**
 120     * Get all relay entries
 121     */
 122    getEntries(): RelayEntry[] {
 123      return Array.from(this._relays.values())
 124    }
 125  
 126    /**
 127     * Get all relays (regardless of scope)
 128     */
 129    getAllRelays(): RelayUrl[] {
 130      return Array.from(this._relays.values()).map((e) => e.relay)
 131    }
 132  
 133    /**
 134     * Get all relay URLs as strings
 135     */
 136    getAllUrls(): string[] {
 137      return Array.from(this._relays.keys())
 138    }
 139  
 140    /**
 141     * Get read relays (scope is 'read' or 'both')
 142     */
 143    getReadRelays(): RelayUrl[] {
 144      return Array.from(this._relays.values())
 145        .filter((e) => e.scope === 'read' || e.scope === 'both')
 146        .map((e) => e.relay)
 147    }
 148  
 149    /**
 150     * Get read relay URLs as strings
 151     */
 152    getReadUrls(): string[] {
 153      return this.getReadRelays().map((r) => r.value)
 154    }
 155  
 156    /**
 157     * Get write relays (scope is 'write' or 'both')
 158     */
 159    getWriteRelays(): RelayUrl[] {
 160      return Array.from(this._relays.values())
 161        .filter((e) => e.scope === 'write' || e.scope === 'both')
 162        .map((e) => e.relay)
 163    }
 164  
 165    /**
 166     * Get write relay URLs as strings
 167     */
 168    getWriteUrls(): string[] {
 169      return this.getWriteRelays().map((r) => r.value)
 170    }
 171  
 172    /**
 173     * Check if a relay is in this list
 174     */
 175    hasRelay(relay: RelayUrl): boolean {
 176      return this._relays.has(relay.value)
 177    }
 178  
 179    /**
 180     * Get the scope for a relay
 181     */
 182    getScope(relay: RelayUrl): RelayScope | null {
 183      const entry = this._relays.get(relay.value)
 184      return entry ? entry.scope : null
 185    }
 186  
 187    /**
 188     * Add or update a relay with a specific scope
 189     *
 190     * @returns RelayListChange indicating what changed
 191     */
 192    setRelay(relay: RelayUrl, scope: RelayScope): RelayListChange {
 193      const existing = this._relays.get(relay.value)
 194  
 195      if (existing) {
 196        if (existing.scope === scope) {
 197          return { type: 'no_change' }
 198        }
 199        const oldScope = existing.scope
 200        this._relays.set(relay.value, { relay, scope })
 201        return { type: 'scope_changed', relay, from: oldScope, to: scope }
 202      }
 203  
 204      this._relays.set(relay.value, { relay, scope })
 205      return { type: 'added', relay, scope }
 206    }
 207  
 208    /**
 209     * Add a relay by URL string
 210     *
 211     * @returns RelayListChange or null if URL is invalid
 212     */
 213    setRelayUrl(url: string, scope: RelayScope): RelayListChange | null {
 214      const relay = RelayUrl.tryCreate(url)
 215      if (!relay) return null
 216      return this.setRelay(relay, scope)
 217    }
 218  
 219    /**
 220     * Remove a relay from this list
 221     *
 222     * @returns RelayListChange indicating what changed
 223     */
 224    removeRelay(relay: RelayUrl): RelayListChange {
 225      if (!this._relays.has(relay.value)) {
 226        return { type: 'no_change' }
 227      }
 228  
 229      this._relays.delete(relay.value)
 230      return { type: 'removed', relay }
 231    }
 232  
 233    /**
 234     * Remove a relay by URL string
 235     *
 236     * @returns RelayListChange or null if URL is invalid
 237     */
 238    removeRelayUrl(url: string): RelayListChange | null {
 239      const relay = RelayUrl.tryCreate(url)
 240      if (!relay) return null
 241      return this.removeRelay(relay)
 242    }
 243  
 244    /**
 245     * Replace all relays with a new list
 246     */
 247    setEntries(entries: RelayEntry[]): void {
 248      this._relays.clear()
 249      for (const entry of entries) {
 250        this._relays.set(entry.relay.value, entry)
 251      }
 252    }
 253  
 254    /**
 255     * Convert to Nostr event tags format
 256     */
 257    toTags(): string[][] {
 258      return Array.from(this._relays.values()).map((entry) => {
 259        if (entry.scope === 'both') {
 260          return ['r', entry.relay.value]
 261        }
 262        return ['r', entry.relay.value, entry.scope]
 263      })
 264    }
 265  
 266    /**
 267     * Convert to a draft event for publishing
 268     */
 269    toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } {
 270      return {
 271        kind: kinds.RelayList,
 272        content: '',
 273        created_at: Timestamp.now().unix,
 274        tags: this.toTags()
 275      }
 276    }
 277  
 278    /**
 279     * Convert to the legacy TRelayList format
 280     */
 281    toLegacyFormat(): {
 282      read: string[]
 283      write: string[]
 284      originalRelays: Array<{ url: string; scope: RelayScope }>
 285    } {
 286      return {
 287        read: this.getReadUrls(),
 288        write: this.getWriteUrls(),
 289        originalRelays: Array.from(this._relays.values()).map((e) => ({
 290          url: e.relay.value,
 291          scope: e.scope
 292        }))
 293      }
 294    }
 295  }
 296