feat: 24h pulse
This commit is contained in:
parent
b21855c294
commit
ce7afeb250
31 changed files with 1086 additions and 123 deletions
|
|
@ -251,6 +251,7 @@ class ClientService extends EventTarget {
|
|||
Object.entries(filter)
|
||||
.sort()
|
||||
.forEach(([key, value]) => {
|
||||
if (key === 'limit') return
|
||||
if (Array.isArray(value)) {
|
||||
stableFilter[key] = [...value].sort()
|
||||
}
|
||||
|
|
@ -298,7 +299,6 @@ class ClientService extends EventTarget {
|
|||
const newEventIdSet = new Set<string>()
|
||||
const requestCount = subRequests.length
|
||||
const threshold = Math.floor(requestCount / 2)
|
||||
let eventIdSet = new Set<string>()
|
||||
let events: NEvent[] = []
|
||||
let eosedCount = 0
|
||||
|
||||
|
|
@ -313,13 +313,7 @@ class ClientService extends EventTarget {
|
|||
eosedCount++
|
||||
}
|
||||
|
||||
_events.forEach((evt) => {
|
||||
if (eventIdSet.has(evt.id)) return
|
||||
eventIdSet.add(evt.id)
|
||||
events.push(evt)
|
||||
})
|
||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
||||
eventIdSet = new Set(events.map((evt) => evt.id))
|
||||
events = this.mergeTimelines(events, _events)
|
||||
|
||||
if (eosedCount >= threshold) {
|
||||
onEvents(events, eosedCount >= requestCount)
|
||||
|
|
@ -352,6 +346,31 @@ class ClientService extends EventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
private mergeTimelines(a: NEvent[], b: NEvent[]): NEvent[] {
|
||||
if (a.length === 0) return [...b]
|
||||
if (b.length === 0) return [...a]
|
||||
|
||||
const result: NEvent[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < a.length && j < b.length) {
|
||||
const cmp = compareEvents(a[i], b[j])
|
||||
if (cmp > 0) {
|
||||
result.push(a[i])
|
||||
i++
|
||||
} else if (cmp < 0) {
|
||||
result.push(b[j])
|
||||
j++
|
||||
} else {
|
||||
result.push(a[i])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async loadMoreTimeline(key: string, until: number, limit: number) {
|
||||
const timeline = this.timelines[key]
|
||||
if (!timeline) return []
|
||||
|
|
@ -552,9 +571,9 @@ class ClientService extends EventTarget {
|
|||
let cachedEvents: NEvent[] = []
|
||||
let since: number | undefined
|
||||
if (timeline && !Array.isArray(timeline) && timeline.refs.length && needSort) {
|
||||
cachedEvents = (
|
||||
await this.eventDataLoader.loadMany(timeline.refs.slice(0, filter.limit).map(([id]) => id))
|
||||
).filter((evt) => !!evt && !(evt instanceof Error)) as NEvent[]
|
||||
cachedEvents = (await this.eventDataLoader.loadMany(timeline.refs.map(([id]) => id))).filter(
|
||||
(evt) => !!evt && !(evt instanceof Error)
|
||||
) as NEvent[]
|
||||
if (cachedEvents.length) {
|
||||
onEvents([...cachedEvents], false)
|
||||
since = cachedEvents[0].created_at + 1
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class LocalStorageService {
|
|||
private enableSingleColumnLayout: boolean = true
|
||||
private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE
|
||||
private filterOutOnionRelays: boolean = !isTorBrowser()
|
||||
private pinnedPubkeys: Set<string> = new Set()
|
||||
|
||||
constructor() {
|
||||
if (!LocalStorageService.instance) {
|
||||
|
|
@ -75,7 +76,7 @@ class LocalStorageService {
|
|||
this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null
|
||||
const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE)
|
||||
this.noteListMode =
|
||||
noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr)
|
||||
noteListModeStr && ['posts', 'postsAndReplies', '24h'].includes(noteListModeStr)
|
||||
? (noteListModeStr as TNoteListMode)
|
||||
: 'posts'
|
||||
const lastReadNotificationTimeMapStr =
|
||||
|
|
@ -230,6 +231,11 @@ class LocalStorageService {
|
|||
this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false'
|
||||
}
|
||||
|
||||
const pinnedPubkeysStr = window.localStorage.getItem(StorageKey.PINNED_PUBKEYS)
|
||||
if (pinnedPubkeysStr) {
|
||||
this.pinnedPubkeys = new Set(JSON.parse(pinnedPubkeysStr))
|
||||
}
|
||||
|
||||
// Clean up deprecated data
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
||||
|
|
@ -558,6 +564,18 @@ class LocalStorageService {
|
|||
this.filterOutOnionRelays = filterOut
|
||||
window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString())
|
||||
}
|
||||
|
||||
getPinnedPubkeys(): Set<string> {
|
||||
return this.pinnedPubkeys
|
||||
}
|
||||
|
||||
setPinnedPubkeys(pinnedPubkeys: Set<string>) {
|
||||
this.pinnedPubkeys = pinnedPubkeys
|
||||
window.localStorage.setItem(
|
||||
StorageKey.PINNED_PUBKEYS,
|
||||
JSON.stringify(Array.from(this.pinnedPubkeys))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new LocalStorageService()
|
||||
|
|
|
|||
207
src/services/user-aggregation.service.ts
Normal file
207
src/services/user-aggregation.service.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { getEventKey } from '@/lib/event'
|
||||
import storage from '@/services/local-storage.service'
|
||||
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 pinnedPubkeys: Set<string> = new Set()
|
||||
private aggregationStore: Map<string, Map<string, Event[]>> = new Map()
|
||||
private listenersMap: Map<string, Set<() => void>> = new Map()
|
||||
private lastViewedMap: Map<string, number> = new Map()
|
||||
|
||||
constructor() {
|
||||
if (UserAggregationService.instance) {
|
||||
return UserAggregationService.instance
|
||||
}
|
||||
UserAggregationService.instance = this
|
||||
this.pinnedPubkeys = storage.getPinnedPubkeys()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned users management
|
||||
getPinnedPubkeys(): string[] {
|
||||
return [...this.pinnedPubkeys]
|
||||
}
|
||||
|
||||
isPinned(pubkey: string): boolean {
|
||||
return this.pinnedPubkeys.has(pubkey)
|
||||
}
|
||||
|
||||
pinUser(pubkey: string) {
|
||||
this.pinnedPubkeys.add(pubkey)
|
||||
storage.setPinnedPubkeys(this.pinnedPubkeys)
|
||||
}
|
||||
|
||||
unpinUser(pubkey: string) {
|
||||
this.pinnedPubkeys.delete(pubkey)
|
||||
storage.setPinnedPubkeys(this.pinnedPubkeys)
|
||||
}
|
||||
|
||||
togglePin(pubkey: string) {
|
||||
if (this.isPinned(pubkey)) {
|
||||
this.unpinUser(pubkey)
|
||||
} else {
|
||||
this.pinUser(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate events by user
|
||||
aggregateByUser(events: Event[]): TUserAggregation[] {
|
||||
const userEventsMap = new Map<string, Event[]>()
|
||||
const processedKeys = new Set<string>()
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
sortWithPinned(aggregations: TUserAggregation[]): TUserAggregation[] {
|
||||
const pinned: TUserAggregation[] = []
|
||||
const unpinned: TUserAggregation[] = []
|
||||
|
||||
aggregations.forEach((agg) => {
|
||||
if (this.isPinned(agg.pubkey)) {
|
||||
pinned.push(agg)
|
||||
} else {
|
||||
unpinned.push(agg)
|
||||
}
|
||||
})
|
||||
|
||||
return [...pinned, ...unpinned]
|
||||
}
|
||||
|
||||
saveAggregations(feedId: string, aggregations: TUserAggregation[]) {
|
||||
const map = new Map<string, Event[]>()
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue