user-aggregation.service.ts raw

   1  import { getEventKey } from '@/lib/event'
   2  import { TFeedSubRequest } from '@/types'
   3  import dayjs from 'dayjs'
   4  import { Event } from 'nostr-tools'
   5  
   6  export type TUserAggregation = {
   7    pubkey: string
   8    events: Event[]
   9    count: number
  10    lastEventTime: number
  11  }
  12  
  13  class UserAggregationService {
  14    static instance: UserAggregationService
  15  
  16    private aggregationStore: Map<string, Map<string, Event[]>> = new Map()
  17    private listenersMap: Map<string, Set<() => void>> = new Map()
  18    private lastViewedMap: Map<string, number> = new Map()
  19  
  20    constructor() {
  21      if (UserAggregationService.instance) {
  22        return UserAggregationService.instance
  23      }
  24      UserAggregationService.instance = this
  25    }
  26  
  27    subscribeAggregationChange(feedId: string, pubkey: string, listener: () => void) {
  28      return this.subscribe(`aggregation:${feedId}:${pubkey}`, listener)
  29    }
  30  
  31    private notifyAggregationChange(feedId: string, pubkey: string) {
  32      this.notify(`aggregation:${feedId}:${pubkey}`)
  33    }
  34  
  35    subscribeViewedTimeChange(feedId: string, pubkey: string, listener: () => void) {
  36      return this.subscribe(`viewedTime:${feedId}:${pubkey}`, listener)
  37    }
  38  
  39    private notifyViewedTimeChange(feedId: string, pubkey: string) {
  40      this.notify(`viewedTime:${feedId}:${pubkey}`)
  41    }
  42  
  43    private subscribe(type: string, listener: () => void) {
  44      if (!this.listenersMap.has(type)) {
  45        this.listenersMap.set(type, new Set())
  46      }
  47      this.listenersMap.get(type)!.add(listener)
  48  
  49      return () => {
  50        this.listenersMap.get(type)?.delete(listener)
  51        if (this.listenersMap.get(type)?.size === 0) {
  52          this.listenersMap.delete(type)
  53        }
  54      }
  55    }
  56  
  57    private notify(type: string) {
  58      const listeners = this.listenersMap.get(type)
  59      if (listeners) {
  60        listeners.forEach((listener) => listener())
  61      }
  62    }
  63  
  64    // Aggregate events by user
  65    aggregateByUser(events: Event[]): TUserAggregation[] {
  66      const userEventsMap = new Map<string, Event[]>()
  67      const processedKeys = new Set<string>()
  68  
  69      events.forEach((event) => {
  70        const key = getEventKey(event)
  71        if (processedKeys.has(key)) return
  72        processedKeys.add(key)
  73  
  74        const existing = userEventsMap.get(event.pubkey) || []
  75        existing.push(event)
  76        userEventsMap.set(event.pubkey, existing)
  77      })
  78  
  79      const aggregations: TUserAggregation[] = []
  80      userEventsMap.forEach((events, pubkey) => {
  81        if (events.length === 0) {
  82          return
  83        }
  84  
  85        aggregations.push({
  86          pubkey,
  87          events: events,
  88          count: events.length,
  89          lastEventTime: events[0].created_at
  90        })
  91      })
  92  
  93      return aggregations.sort((a, b) => {
  94        return b.lastEventTime - a.lastEventTime
  95      })
  96    }
  97  
  98    saveAggregations(feedId: string, aggregations: TUserAggregation[]) {
  99      const map = new Map<string, Event[]>()
 100      aggregations.forEach((agg) => map.set(agg.pubkey, agg.events))
 101      this.aggregationStore.set(feedId, map)
 102      aggregations.forEach((agg) => {
 103        this.notifyAggregationChange(feedId, agg.pubkey)
 104      })
 105    }
 106  
 107    getAggregation(feedId: string, pubkey: string): Event[] {
 108      return this.aggregationStore.get(feedId)?.get(pubkey) || []
 109    }
 110  
 111    clearAggregations(feedId: string) {
 112      this.aggregationStore.delete(feedId)
 113    }
 114  
 115    getFeedId(subRequests: TFeedSubRequest[], showKinds: number[] = []): string {
 116      const requestStr = subRequests
 117        .map((req) => {
 118          const urls = req.urls.sort().join(',')
 119          const filter = Object.entries(req.filter)
 120            .filter(([key]) => !['since', 'until', 'limit'].includes(key))
 121            .sort(([a], [b]) => a.localeCompare(b))
 122            .map(([key, value]) => `${key}:${JSON.stringify(value)}`)
 123            .join('|')
 124          return `${urls}#${filter}`
 125        })
 126        .join(';;')
 127  
 128      const kindsStr = showKinds.sort((a, b) => a - b).join(',')
 129      const input = `${requestStr}::${kindsStr}`
 130  
 131      let hash = 0
 132      for (let i = 0; i < input.length; i++) {
 133        const char = input.charCodeAt(i)
 134        hash = (hash << 5) - hash + char
 135        hash = hash & hash
 136      }
 137  
 138      return Math.abs(hash).toString(36)
 139    }
 140  
 141    markAsViewed(feedId: string, pubkey: string) {
 142      const key = `${feedId}:${pubkey}`
 143      this.lastViewedMap.set(key, dayjs().unix())
 144      this.notifyViewedTimeChange(feedId, pubkey)
 145    }
 146  
 147    markAsUnviewed(feedId: string, pubkey: string) {
 148      const key = `${feedId}:${pubkey}`
 149      this.lastViewedMap.delete(key)
 150      this.notifyViewedTimeChange(feedId, pubkey)
 151    }
 152  
 153    getLastViewedTime(feedId: string, pubkey: string): number {
 154      const key = `${feedId}:${pubkey}`
 155      const lastViewed = this.lastViewedMap.get(key)
 156  
 157      return lastViewed ?? 0
 158    }
 159  }
 160  
 161  const userAggregationService = new UserAggregationService()
 162  export default userAggregationService
 163