import { getEventKey } from '@/lib/event' import { TFeedSubRequest } from '@/types' import dayjs from 'dayjs' import { Event } from 'nostr-tools' export type TUserAggregation = { pubkey: string events: Event[] count: number lastEventTime: number } class UserAggregationService { static instance: UserAggregationService private aggregationStore: Map> = new Map() private listenersMap: Map void>> = new Map() private lastViewedMap: Map = new Map() constructor() { if (UserAggregationService.instance) { return UserAggregationService.instance } UserAggregationService.instance = this } subscribeAggregationChange(feedId: string, pubkey: string, listener: () => void) { return this.subscribe(`aggregation:${feedId}:${pubkey}`, listener) } private notifyAggregationChange(feedId: string, pubkey: string) { this.notify(`aggregation:${feedId}:${pubkey}`) } subscribeViewedTimeChange(feedId: string, pubkey: string, listener: () => void) { return this.subscribe(`viewedTime:${feedId}:${pubkey}`, listener) } private notifyViewedTimeChange(feedId: string, pubkey: string) { this.notify(`viewedTime:${feedId}:${pubkey}`) } private subscribe(type: string, listener: () => void) { if (!this.listenersMap.has(type)) { this.listenersMap.set(type, new Set()) } this.listenersMap.get(type)!.add(listener) return () => { this.listenersMap.get(type)?.delete(listener) if (this.listenersMap.get(type)?.size === 0) { this.listenersMap.delete(type) } } } private notify(type: string) { const listeners = this.listenersMap.get(type) if (listeners) { listeners.forEach((listener) => listener()) } } // Aggregate events by user aggregateByUser(events: Event[]): TUserAggregation[] { const userEventsMap = new Map() const processedKeys = new Set() events.forEach((event) => { const key = getEventKey(event) if (processedKeys.has(key)) return processedKeys.add(key) const existing = userEventsMap.get(event.pubkey) || [] existing.push(event) userEventsMap.set(event.pubkey, existing) }) const aggregations: TUserAggregation[] = [] userEventsMap.forEach((events, pubkey) => { if (events.length === 0) { return } aggregations.push({ pubkey, events: events, count: events.length, lastEventTime: events[0].created_at }) }) return aggregations.sort((a, b) => { return b.lastEventTime - a.lastEventTime }) } saveAggregations(feedId: string, aggregations: TUserAggregation[]) { const map = new Map() aggregations.forEach((agg) => map.set(agg.pubkey, agg.events)) this.aggregationStore.set(feedId, map) aggregations.forEach((agg) => { this.notifyAggregationChange(feedId, agg.pubkey) }) } getAggregation(feedId: string, pubkey: string): Event[] { return this.aggregationStore.get(feedId)?.get(pubkey) || [] } clearAggregations(feedId: string) { this.aggregationStore.delete(feedId) } getFeedId(subRequests: TFeedSubRequest[], showKinds: number[] = []): string { const requestStr = subRequests .map((req) => { const urls = req.urls.sort().join(',') const filter = Object.entries(req.filter) .filter(([key]) => !['since', 'until', 'limit'].includes(key)) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `${key}:${JSON.stringify(value)}`) .join('|') return `${urls}#${filter}` }) .join(';;') const kindsStr = showKinds.sort((a, b) => a - b).join(',') const input = `${requestStr}::${kindsStr}` let hash = 0 for (let i = 0; i < input.length; i++) { const char = input.charCodeAt(i) hash = (hash << 5) - hash + char hash = hash & hash } return Math.abs(hash).toString(36) } markAsViewed(feedId: string, pubkey: string) { const key = `${feedId}:${pubkey}` this.lastViewedMap.set(key, dayjs().unix()) this.notifyViewedTimeChange(feedId, pubkey) } markAsUnviewed(feedId: string, pubkey: string) { const key = `${feedId}:${pubkey}` this.lastViewedMap.delete(key) this.notifyViewedTimeChange(feedId, pubkey) } getLastViewedTime(feedId: string, pubkey: string): number { const key = `${feedId}:${pubkey}` const lastViewed = this.lastViewedMap.get(key) return lastViewed ?? 0 } } const userAggregationService = new UserAggregationService() export default userAggregationService