feat: 24h pulse

This commit is contained in:
codytseng 2025-11-29 00:34:53 +08:00
parent b21855c294
commit ce7afeb250
31 changed files with 1086 additions and 123 deletions

View file

@ -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

View file

@ -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()

View 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