MuteList.ts raw

   1  import { Event, kinds } from 'nostr-tools'
   2  import { Pubkey, Timestamp } from '../shared'
   3  import { CannotMuteSelfError } from './errors'
   4  
   5  /**
   6   * Type of mute visibility
   7   */
   8  export type MuteVisibility = 'public' | 'private'
   9  
  10  /**
  11   * A muted entry (user or other content)
  12   */
  13  export type MuteEntry = {
  14    pubkey: Pubkey
  15    visibility: MuteVisibility
  16  }
  17  
  18  /**
  19   * Result of a mute/unmute operation
  20   */
  21  export type MuteListChange =
  22    | { type: 'muted'; pubkey: Pubkey; visibility: MuteVisibility }
  23    | { type: 'unmuted'; pubkey: Pubkey }
  24    | { type: 'visibility_changed'; pubkey: Pubkey; from: MuteVisibility; to: MuteVisibility }
  25    | { type: 'no_change' }
  26  
  27  /**
  28   * MuteList Aggregate
  29   *
  30   * Represents a user's mute list (kind 10000 event in Nostr).
  31   * Supports both public mutes (visible to others) and private mutes (encrypted).
  32   *
  33   * Invariants:
  34   * - Cannot mute self
  35   * - No duplicate entries (a user can only be muted once, either public or private)
  36   * - Pubkeys must be valid
  37   *
  38   * Note: The encryption/decryption of private mutes is handled at the infrastructure layer.
  39   * This aggregate works with already-decrypted private tags.
  40   */
  41  export class MuteList {
  42    private readonly _publicMutes: Map<string, Pubkey>
  43    private readonly _privateMutes: Map<string, Pubkey>
  44  
  45    private constructor(
  46      private readonly _owner: Pubkey,
  47      publicMutes: Pubkey[],
  48      privateMutes: Pubkey[]
  49    ) {
  50      this._publicMutes = new Map()
  51      this._privateMutes = new Map()
  52  
  53      for (const pubkey of publicMutes) {
  54        this._publicMutes.set(pubkey.hex, pubkey)
  55      }
  56      for (const pubkey of privateMutes) {
  57        this._privateMutes.set(pubkey.hex, pubkey)
  58      }
  59    }
  60  
  61    /**
  62     * Create an empty MuteList for a user
  63     */
  64    static empty(owner: Pubkey): MuteList {
  65      return new MuteList(owner, [], [])
  66    }
  67  
  68    /**
  69     * Reconstruct a MuteList from a Nostr kind 10000 event
  70     *
  71     * @param event The mute list event
  72     * @param decryptedPrivateTags The decrypted private tags (if any)
  73     */
  74    static fromEvent(event: Event, decryptedPrivateTags: string[][] = []): MuteList {
  75      if (event.kind !== kinds.Mutelist) {
  76        throw new Error(`Expected kind ${kinds.Mutelist}, got ${event.kind}`)
  77      }
  78  
  79      const owner = Pubkey.fromHex(event.pubkey)
  80      const publicMutes: Pubkey[] = []
  81      const privateMutes: Pubkey[] = []
  82  
  83      // Extract public mutes from event tags
  84      for (const tag of event.tags) {
  85        if (tag[0] === 'p' && tag[1]) {
  86          const pubkey = Pubkey.tryFromString(tag[1])
  87          if (pubkey) {
  88            publicMutes.push(pubkey)
  89          }
  90        }
  91      }
  92  
  93      // Extract private mutes from decrypted content
  94      for (const tag of decryptedPrivateTags) {
  95        if (tag[0] === 'p' && tag[1]) {
  96          const pubkey = Pubkey.tryFromString(tag[1])
  97          if (pubkey) {
  98            privateMutes.push(pubkey)
  99          }
 100        }
 101      }
 102  
 103      return new MuteList(owner, publicMutes, privateMutes)
 104    }
 105  
 106    /**
 107     * The owner of this mute list
 108     */
 109    get owner(): Pubkey {
 110      return this._owner
 111    }
 112  
 113    /**
 114     * Total number of muted users
 115     */
 116    get count(): number {
 117      return this._publicMutes.size + this._privateMutes.size
 118    }
 119  
 120    /**
 121     * Number of publicly muted users
 122     */
 123    get publicCount(): number {
 124      return this._publicMutes.size
 125    }
 126  
 127    /**
 128     * Number of privately muted users
 129     */
 130    get privateCount(): number {
 131      return this._privateMutes.size
 132    }
 133  
 134    /**
 135     * Get all muted pubkeys (both public and private)
 136     */
 137    getAllMuted(): Pubkey[] {
 138      return [...this.getPublicMuted(), ...this.getPrivateMuted()]
 139    }
 140  
 141    /**
 142     * Get publicly muted pubkeys
 143     */
 144    getPublicMuted(): Pubkey[] {
 145      return Array.from(this._publicMutes.values())
 146    }
 147  
 148    /**
 149     * Get privately muted pubkeys
 150     */
 151    getPrivateMuted(): Pubkey[] {
 152      return Array.from(this._privateMutes.values())
 153    }
 154  
 155    /**
 156     * Check if a user is muted (either publicly or privately)
 157     */
 158    isMuted(pubkey: Pubkey): boolean {
 159      return this._publicMutes.has(pubkey.hex) || this._privateMutes.has(pubkey.hex)
 160    }
 161  
 162    /**
 163     * Get the mute visibility for a user
 164     */
 165    getMuteVisibility(pubkey: Pubkey): MuteVisibility | null {
 166      if (this._publicMutes.has(pubkey.hex)) return 'public'
 167      if (this._privateMutes.has(pubkey.hex)) return 'private'
 168      return null
 169    }
 170  
 171    /**
 172     * Mute a user publicly
 173     *
 174     * @throws CannotMuteSelfError if attempting to mute self
 175     * @returns MuteListChange indicating what changed
 176     */
 177    mutePublicly(pubkey: Pubkey): MuteListChange {
 178      if (pubkey.equals(this._owner)) {
 179        throw new CannotMuteSelfError()
 180      }
 181  
 182      // Already publicly muted
 183      if (this._publicMutes.has(pubkey.hex)) {
 184        return { type: 'no_change' }
 185      }
 186  
 187      // Was privately muted, switch to public
 188      if (this._privateMutes.has(pubkey.hex)) {
 189        this._privateMutes.delete(pubkey.hex)
 190        this._publicMutes.set(pubkey.hex, pubkey)
 191        return { type: 'visibility_changed', pubkey, from: 'private', to: 'public' }
 192      }
 193  
 194      // New public mute
 195      this._publicMutes.set(pubkey.hex, pubkey)
 196      return { type: 'muted', pubkey, visibility: 'public' }
 197    }
 198  
 199    /**
 200     * Mute a user privately
 201     *
 202     * @throws CannotMuteSelfError if attempting to mute self
 203     * @returns MuteListChange indicating what changed
 204     */
 205    mutePrivately(pubkey: Pubkey): MuteListChange {
 206      if (pubkey.equals(this._owner)) {
 207        throw new CannotMuteSelfError()
 208      }
 209  
 210      // Already privately muted
 211      if (this._privateMutes.has(pubkey.hex)) {
 212        return { type: 'no_change' }
 213      }
 214  
 215      // Was publicly muted, switch to private
 216      if (this._publicMutes.has(pubkey.hex)) {
 217        this._publicMutes.delete(pubkey.hex)
 218        this._privateMutes.set(pubkey.hex, pubkey)
 219        return { type: 'visibility_changed', pubkey, from: 'public', to: 'private' }
 220      }
 221  
 222      // New private mute
 223      this._privateMutes.set(pubkey.hex, pubkey)
 224      return { type: 'muted', pubkey, visibility: 'private' }
 225    }
 226  
 227    /**
 228     * Unmute a user (removes from both public and private)
 229     *
 230     * @returns MuteListChange indicating what changed
 231     */
 232    unmute(pubkey: Pubkey): MuteListChange {
 233      if (this._publicMutes.has(pubkey.hex)) {
 234        this._publicMutes.delete(pubkey.hex)
 235        return { type: 'unmuted', pubkey }
 236      }
 237  
 238      if (this._privateMutes.has(pubkey.hex)) {
 239        this._privateMutes.delete(pubkey.hex)
 240        return { type: 'unmuted', pubkey }
 241      }
 242  
 243      return { type: 'no_change' }
 244    }
 245  
 246    /**
 247     * Switch a public mute to private
 248     *
 249     * @returns MuteListChange indicating what changed
 250     */
 251    switchToPrivate(pubkey: Pubkey): MuteListChange {
 252      return this.mutePrivately(pubkey)
 253    }
 254  
 255    /**
 256     * Switch a private mute to public
 257     *
 258     * @returns MuteListChange indicating what changed
 259     */
 260    switchToPublic(pubkey: Pubkey): MuteListChange {
 261      return this.mutePublicly(pubkey)
 262    }
 263  
 264    /**
 265     * Convert public mutes to Nostr event tags format
 266     */
 267    toPublicTags(): string[][] {
 268      return Array.from(this._publicMutes.values()).map((pubkey) => ['p', pubkey.hex])
 269    }
 270  
 271    /**
 272     * Convert private mutes to tags format (for encryption)
 273     */
 274    toPrivateTags(): string[][] {
 275      return Array.from(this._privateMutes.values()).map((pubkey) => ['p', pubkey.hex])
 276    }
 277  
 278    /**
 279     * Convert to a draft event for publishing
 280     *
 281     * Note: The content field should be encrypted by the caller using NIP-04
 282     * with JSON.stringify(this.toPrivateTags())
 283     *
 284     * @param encryptedContent The NIP-04 encrypted private tags
 285     */
 286    toDraftEvent(encryptedContent: string = ''): {
 287      kind: number
 288      content: string
 289      created_at: number
 290      tags: string[][]
 291    } {
 292      return {
 293        kind: kinds.Mutelist,
 294        content: encryptedContent,
 295        created_at: Timestamp.now().unix,
 296        tags: this.toPublicTags()
 297      }
 298    }
 299  
 300    /**
 301     * Check if private mutes need to be encrypted/updated
 302     * Returns true if there are private mutes that need to be persisted
 303     */
 304    hasPrivateMutes(): boolean {
 305      return this._privateMutes.size > 0
 306    }
 307  }
 308