FeedFilter.ts raw

   1  import { Event } from 'nostr-tools'
   2  import { ContentFilter, FilterContext, FilterResult, FilterReason } from './ContentFilter'
   3  
   4  /**
   5   * Interface for checking if a pubkey is muted
   6   */
   7  export interface MuteChecker {
   8    isMuted(pubkey: string): boolean
   9    getMutedPubkeys(): Set<string>
  10  }
  11  
  12  /**
  13   * Interface for checking if a pubkey is trusted
  14   */
  15  export interface TrustChecker {
  16    isTrusted(pubkey: string): boolean
  17    getTrustedPubkeys(): Set<string>
  18  }
  19  
  20  /**
  21   * Interface for checking if an event is deleted
  22   */
  23  export interface DeletionChecker {
  24    isDeleted(eventId: string): boolean
  25    getDeletedEventIds(): Set<string>
  26  }
  27  
  28  /**
  29   * Interface for checking if an event is pinned
  30   */
  31  export interface PinnedChecker {
  32    isPinned(eventId: string): boolean
  33    getPinnedEventIds(): Set<string>
  34  }
  35  
  36  /**
  37   * Result of filtering with the original event
  38   */
  39  export interface FilteredEvent {
  40    event: Event
  41    result: FilterResult
  42  }
  43  
  44  /**
  45   * Statistics about filtering results
  46   */
  47  export interface FilterStats {
  48    total: number
  49    shown: number
  50    hidden: number
  51    byReason: Map<FilterReason, number>
  52  }
  53  
  54  /**
  55   * FeedFilter Domain Service
  56   *
  57   * Coordinates filtering of timeline events using ContentFilter and various
  58   * checkers (mute, trust, deletion). This is a domain service because it
  59   * requires coordination between multiple domain concepts.
  60   *
  61   * Usage:
  62   * - Inject checkers that provide mute/trust/deletion data
  63   * - Call filterEvents() to filter a batch of events
  64   * - Call shouldDisplay() to check a single event
  65   */
  66  export class FeedFilter {
  67    constructor(
  68      private readonly muteChecker: MuteChecker,
  69      private readonly trustChecker?: TrustChecker,
  70      private readonly deletionChecker?: DeletionChecker,
  71      private readonly pinnedChecker?: PinnedChecker,
  72      private readonly currentUserPubkey?: string
  73    ) {}
  74  
  75    /**
  76     * Create a filter context from the current checker state
  77     */
  78    private buildContext(): FilterContext {
  79      return {
  80        mutedPubkeys: this.muteChecker.getMutedPubkeys(),
  81        trustedPubkeys: this.trustChecker?.getTrustedPubkeys(),
  82        deletedEventIds: this.deletionChecker?.getDeletedEventIds(),
  83        pinnedEventIds: this.pinnedChecker?.getPinnedEventIds(),
  84        currentUserPubkey: this.currentUserPubkey
  85      }
  86    }
  87  
  88    /**
  89     * Filter a batch of events, returning only those that should be shown
  90     */
  91    filterEvents(events: Event[], filter: ContentFilter): Event[] {
  92      const context = this.buildContext()
  93      return events.filter((event) => filter.shouldShow(event, context).shouldShow)
  94    }
  95  
  96    /**
  97     * Filter events and return both shown and hidden with reasons
  98     */
  99    filterEventsWithDetails(events: Event[], filter: ContentFilter): FilteredEvent[] {
 100      const context = this.buildContext()
 101      return events.map((event) => ({
 102        event,
 103        result: filter.shouldShow(event, context)
 104      }))
 105    }
 106  
 107    /**
 108     * Get only events that should be shown with their filter results
 109     */
 110    getShownEvents(events: Event[], filter: ContentFilter): FilteredEvent[] {
 111      return this.filterEventsWithDetails(events, filter).filter((fe) => fe.result.shouldShow)
 112    }
 113  
 114    /**
 115     * Get only events that were hidden with their reasons
 116     */
 117    getHiddenEvents(events: Event[], filter: ContentFilter): FilteredEvent[] {
 118      return this.filterEventsWithDetails(events, filter).filter((fe) => !fe.result.shouldShow)
 119    }
 120  
 121    /**
 122     * Check if a single event should be displayed
 123     */
 124    shouldDisplay(event: Event, filter: ContentFilter): FilterResult {
 125      const context = this.buildContext()
 126      return filter.shouldShow(event, context)
 127    }
 128  
 129    /**
 130     * Get statistics about filtering a batch of events
 131     */
 132    getFilterStats(events: Event[], filter: ContentFilter): FilterStats {
 133      const results = this.filterEventsWithDetails(events, filter)
 134      const byReason = new Map<FilterReason, number>()
 135  
 136      let shown = 0
 137      let hidden = 0
 138  
 139      for (const { result } of results) {
 140        if (result.shouldShow) {
 141          shown++
 142        } else {
 143          hidden++
 144          if (result.reason) {
 145            byReason.set(result.reason, (byReason.get(result.reason) ?? 0) + 1)
 146          }
 147        }
 148      }
 149  
 150      return {
 151        total: events.length,
 152        shown,
 153        hidden,
 154        byReason
 155      }
 156    }
 157  
 158    /**
 159     * Create a new FeedFilter with an updated mute checker
 160     */
 161    withMuteChecker(muteChecker: MuteChecker): FeedFilter {
 162      return new FeedFilter(
 163        muteChecker,
 164        this.trustChecker,
 165        this.deletionChecker,
 166        this.pinnedChecker,
 167        this.currentUserPubkey
 168      )
 169    }
 170  
 171    /**
 172     * Create a new FeedFilter with an updated trust checker
 173     */
 174    withTrustChecker(trustChecker: TrustChecker): FeedFilter {
 175      return new FeedFilter(
 176        this.muteChecker,
 177        trustChecker,
 178        this.deletionChecker,
 179        this.pinnedChecker,
 180        this.currentUserPubkey
 181      )
 182    }
 183  
 184    /**
 185     * Create a new FeedFilter with an updated deletion checker
 186     */
 187    withDeletionChecker(deletionChecker: DeletionChecker): FeedFilter {
 188      return new FeedFilter(
 189        this.muteChecker,
 190        this.trustChecker,
 191        deletionChecker,
 192        this.pinnedChecker,
 193        this.currentUserPubkey
 194      )
 195    }
 196  
 197    /**
 198     * Create a new FeedFilter with an updated pinned checker
 199     */
 200    withPinnedChecker(pinnedChecker: PinnedChecker): FeedFilter {
 201      return new FeedFilter(
 202        this.muteChecker,
 203        this.trustChecker,
 204        this.deletionChecker,
 205        pinnedChecker,
 206        this.currentUserPubkey
 207      )
 208    }
 209  
 210    /**
 211     * Create a new FeedFilter with an updated current user
 212     */
 213    withCurrentUser(pubkey: string): FeedFilter {
 214      return new FeedFilter(
 215        this.muteChecker,
 216        this.trustChecker,
 217        this.deletionChecker,
 218        this.pinnedChecker,
 219        pubkey
 220      )
 221    }
 222  }
 223  
 224  /**
 225   * Simple in-memory implementation of MuteChecker for testing
 226   */
 227  export class SimpleMuteChecker implements MuteChecker {
 228    constructor(private readonly mutedPubkeys: Set<string> = new Set()) {}
 229  
 230    isMuted(pubkey: string): boolean {
 231      return this.mutedPubkeys.has(pubkey)
 232    }
 233  
 234    getMutedPubkeys(): Set<string> {
 235      return this.mutedPubkeys
 236    }
 237  }
 238  
 239  /**
 240   * Simple in-memory implementation of TrustChecker for testing
 241   */
 242  export class SimpleTrustChecker implements TrustChecker {
 243    constructor(private readonly trustedPubkeys: Set<string> = new Set()) {}
 244  
 245    isTrusted(pubkey: string): boolean {
 246      return this.trustedPubkeys.has(pubkey)
 247    }
 248  
 249    getTrustedPubkeys(): Set<string> {
 250      return this.trustedPubkeys
 251    }
 252  }
 253  
 254  /**
 255   * Simple in-memory implementation of DeletionChecker for testing
 256   */
 257  export class SimpleDeletionChecker implements DeletionChecker {
 258    constructor(private readonly deletedEventIds: Set<string> = new Set()) {}
 259  
 260    isDeleted(eventId: string): boolean {
 261      return this.deletedEventIds.has(eventId)
 262    }
 263  
 264    getDeletedEventIds(): Set<string> {
 265      return this.deletedEventIds
 266    }
 267  }
 268  
 269  /**
 270   * Simple in-memory implementation of PinnedChecker for testing
 271   */
 272  export class SimplePinnedChecker implements PinnedChecker {
 273    constructor(private readonly pinnedEventIds: Set<string> = new Set()) {}
 274  
 275    isPinned(eventId: string): boolean {
 276      return this.pinnedEventIds.has(eventId)
 277    }
 278  
 279    getPinnedEventIds(): Set<string> {
 280      return this.pinnedEventIds
 281    }
 282  }
 283