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