ContentFilter.ts raw

   1  import { Event } from 'nostr-tools'
   2  
   3  /**
   4   * NSFW display policy options
   5   */
   6  export type NsfwDisplayPolicy = 'hide' | 'hide_content' | 'show'
   7  
   8  /**
   9   * Context required for filtering decisions
  10   */
  11  export interface FilterContext {
  12    mutedPubkeys: Set<string>
  13    trustedPubkeys?: Set<string>
  14    deletedEventIds?: Set<string>
  15    currentUserPubkey?: string
  16    pinnedEventIds?: Set<string>
  17  }
  18  
  19  /**
  20   * Result of a filter check with reason
  21   */
  22  export type FilterResult = {
  23    shouldShow: boolean
  24    reason?: FilterReason
  25  }
  26  
  27  /**
  28   * Reason why an event was filtered
  29   */
  30  export type FilterReason =
  31    | 'muted_author'
  32    | 'mentions_muted_user'
  33    | 'untrusted_author'
  34    | 'deleted'
  35    | 'reply_filtered'
  36    | 'repost_filtered'
  37    | 'nsfw_hidden'
  38    | 'kind_not_allowed'
  39  
  40  /**
  41   * ContentFilter Value Object
  42   *
  43   * Encapsulates all filtering criteria for timeline content.
  44   * Immutable - all modifications return new instances.
  45   */
  46  export class ContentFilter {
  47    private constructor(
  48      private readonly _hideMutedUsers: boolean,
  49      private readonly _hideContentMentioningMuted: boolean,
  50      private readonly _hideUntrustedUsers: boolean,
  51      private readonly _hideReplies: boolean,
  52      private readonly _hideReposts: boolean,
  53      private readonly _allowedKinds: readonly number[],
  54      private readonly _nsfwPolicy: NsfwDisplayPolicy
  55    ) {}
  56  
  57    /**
  58     * Create default content filter with sensible defaults
  59     */
  60    static default(): ContentFilter {
  61      return new ContentFilter(
  62        true, // hideMutedUsers
  63        true, // hideContentMentioningMuted
  64        false, // hideUntrustedUsers
  65        false, // hideReplies
  66        false, // hideReposts
  67        [], // allowedKinds (empty = allow all)
  68        'hide_content' // nsfwPolicy
  69      )
  70    }
  71  
  72    /**
  73     * Create filter from user preferences
  74     */
  75    static fromPreferences(prefs: {
  76      hideMutedUsers?: boolean
  77      hideContentMentioningMuted?: boolean
  78      hideUntrustedUsers?: boolean
  79      hideReplies?: boolean
  80      hideReposts?: boolean
  81      allowedKinds?: number[]
  82      nsfwPolicy?: NsfwDisplayPolicy
  83    }): ContentFilter {
  84      return new ContentFilter(
  85        prefs.hideMutedUsers ?? true,
  86        prefs.hideContentMentioningMuted ?? true,
  87        prefs.hideUntrustedUsers ?? false,
  88        prefs.hideReplies ?? false,
  89        prefs.hideReposts ?? false,
  90        prefs.allowedKinds ?? [],
  91        prefs.nsfwPolicy ?? 'hide_content'
  92      )
  93    }
  94  
  95    // Getters
  96    get hideMutedUsers(): boolean {
  97      return this._hideMutedUsers
  98    }
  99  
 100    get hideContentMentioningMuted(): boolean {
 101      return this._hideContentMentioningMuted
 102    }
 103  
 104    get hideUntrustedUsers(): boolean {
 105      return this._hideUntrustedUsers
 106    }
 107  
 108    get hideReplies(): boolean {
 109      return this._hideReplies
 110    }
 111  
 112    get hideReposts(): boolean {
 113      return this._hideReposts
 114    }
 115  
 116    get allowedKinds(): readonly number[] {
 117      return this._allowedKinds
 118    }
 119  
 120    get nsfwPolicy(): NsfwDisplayPolicy {
 121      return this._nsfwPolicy
 122    }
 123  
 124    /**
 125     * Check if a kind is allowed by this filter
 126     */
 127    isKindAllowed(kind: number): boolean {
 128      // Empty array means all kinds allowed
 129      if (this._allowedKinds.length === 0) return true
 130      return this._allowedKinds.includes(kind)
 131    }
 132  
 133    /**
 134     * Check if an event should be shown based on this filter and context
 135     */
 136    shouldShow(event: Event, context: FilterContext): FilterResult {
 137      // Check kind filter first
 138      if (!this.isKindAllowed(event.kind)) {
 139        return { shouldShow: false, reason: 'kind_not_allowed' }
 140      }
 141  
 142      // Check if event is pinned (pinned events bypass most filters)
 143      if (context.pinnedEventIds?.has(event.id)) {
 144        return { shouldShow: true }
 145      }
 146  
 147      // Check deleted
 148      if (context.deletedEventIds?.has(event.id)) {
 149        return { shouldShow: false, reason: 'deleted' }
 150      }
 151  
 152      // Check muted author
 153      if (this._hideMutedUsers && context.mutedPubkeys.has(event.pubkey)) {
 154        return { shouldShow: false, reason: 'muted_author' }
 155      }
 156  
 157      // Check if content mentions muted users
 158      if (this._hideContentMentioningMuted) {
 159        const mentionedPubkeys = this.extractMentionedPubkeys(event)
 160        for (const pk of mentionedPubkeys) {
 161          if (context.mutedPubkeys.has(pk)) {
 162            return { shouldShow: false, reason: 'mentions_muted_user' }
 163          }
 164        }
 165      }
 166  
 167      // Check untrusted
 168      if (this._hideUntrustedUsers && context.trustedPubkeys) {
 169        if (!context.trustedPubkeys.has(event.pubkey)) {
 170          return { shouldShow: false, reason: 'untrusted_author' }
 171        }
 172      }
 173  
 174      // Check reply filter
 175      if (this._hideReplies && this.isReply(event)) {
 176        return { shouldShow: false, reason: 'reply_filtered' }
 177      }
 178  
 179      // Check repost filter
 180      if (this._hideReposts && this.isRepost(event)) {
 181        return { shouldShow: false, reason: 'repost_filtered' }
 182      }
 183  
 184      return { shouldShow: true }
 185    }
 186  
 187    /**
 188     * Extract pubkeys mentioned in an event
 189     */
 190    private extractMentionedPubkeys(event: Event): string[] {
 191      const pubkeys: string[] = []
 192      for (const tag of event.tags) {
 193        if (tag[0] === 'p' && tag[1]) {
 194          pubkeys.push(tag[1])
 195        }
 196      }
 197      return pubkeys
 198    }
 199  
 200    /**
 201     * Check if event is a reply
 202     */
 203    private isReply(event: Event): boolean {
 204      // Check for 'e' or 'E' tags with reply marker, or just any 'e' tag
 205      for (const tag of event.tags) {
 206        if ((tag[0] === 'e' || tag[0] === 'E') && tag[1]) {
 207          // If marker is 'reply' or 'root', it's a reply
 208          if (tag[3] === 'reply' || tag[3] === 'root') {
 209            return true
 210          }
 211          // Legacy: any 'e' tag indicates reply
 212          return true
 213        }
 214      }
 215      return false
 216    }
 217  
 218    /**
 219     * Check if event is a repost
 220     */
 221    private isRepost(event: Event): boolean {
 222      return event.kind === 6 || event.kind === 16
 223    }
 224  
 225    // Immutable modification methods
 226    withHideMutedUsers(hide: boolean): ContentFilter {
 227      return new ContentFilter(
 228        hide,
 229        this._hideContentMentioningMuted,
 230        this._hideUntrustedUsers,
 231        this._hideReplies,
 232        this._hideReposts,
 233        this._allowedKinds,
 234        this._nsfwPolicy
 235      )
 236    }
 237  
 238    withHideContentMentioningMuted(hide: boolean): ContentFilter {
 239      return new ContentFilter(
 240        this._hideMutedUsers,
 241        hide,
 242        this._hideUntrustedUsers,
 243        this._hideReplies,
 244        this._hideReposts,
 245        this._allowedKinds,
 246        this._nsfwPolicy
 247      )
 248    }
 249  
 250    withHideUntrustedUsers(hide: boolean): ContentFilter {
 251      return new ContentFilter(
 252        this._hideMutedUsers,
 253        this._hideContentMentioningMuted,
 254        hide,
 255        this._hideReplies,
 256        this._hideReposts,
 257        this._allowedKinds,
 258        this._nsfwPolicy
 259      )
 260    }
 261  
 262    withHideReplies(hide: boolean): ContentFilter {
 263      return new ContentFilter(
 264        this._hideMutedUsers,
 265        this._hideContentMentioningMuted,
 266        this._hideUntrustedUsers,
 267        hide,
 268        this._hideReposts,
 269        this._allowedKinds,
 270        this._nsfwPolicy
 271      )
 272    }
 273  
 274    withHideReposts(hide: boolean): ContentFilter {
 275      return new ContentFilter(
 276        this._hideMutedUsers,
 277        this._hideContentMentioningMuted,
 278        this._hideUntrustedUsers,
 279        this._hideReplies,
 280        hide,
 281        this._allowedKinds,
 282        this._nsfwPolicy
 283      )
 284    }
 285  
 286    withAllowedKinds(kinds: number[]): ContentFilter {
 287      return new ContentFilter(
 288        this._hideMutedUsers,
 289        this._hideContentMentioningMuted,
 290        this._hideUntrustedUsers,
 291        this._hideReplies,
 292        this._hideReposts,
 293        [...kinds],
 294        this._nsfwPolicy
 295      )
 296    }
 297  
 298    withNsfwPolicy(policy: NsfwDisplayPolicy): ContentFilter {
 299      return new ContentFilter(
 300        this._hideMutedUsers,
 301        this._hideContentMentioningMuted,
 302        this._hideUntrustedUsers,
 303        this._hideReplies,
 304        this._hideReposts,
 305        this._allowedKinds,
 306        policy
 307      )
 308    }
 309  
 310    equals(other: ContentFilter): boolean {
 311      if (this._hideMutedUsers !== other._hideMutedUsers) return false
 312      if (this._hideContentMentioningMuted !== other._hideContentMentioningMuted) return false
 313      if (this._hideUntrustedUsers !== other._hideUntrustedUsers) return false
 314      if (this._hideReplies !== other._hideReplies) return false
 315      if (this._hideReposts !== other._hideReposts) return false
 316      if (this._nsfwPolicy !== other._nsfwPolicy) return false
 317      if (this._allowedKinds.length !== other._allowedKinds.length) return false
 318      for (let i = 0; i < this._allowedKinds.length; i++) {
 319        if (this._allowedKinds[i] !== other._allowedKinds[i]) return false
 320      }
 321      return true
 322    }
 323  }
 324