import { Func, Inbox, Lifecycle, spawn, loop, STOP } from 'anneal' import { Filter, Event as NEvent, matchFilters } from 'nostr-tools' import { SimplePool } from 'nostr-tools' import { AbstractRelay } from 'nostr-tools/abstract-relay' import { sha256 } from '@noble/hashes/sha2' import dayjs from 'dayjs' import { ISigner, TSubRequestFilter } from '@/types' import { EventCacheActor } from './event-cache.actor' import { createSubscription } from './subscription.actor' import { TTimelineRef, TimelineData, SubscribeReq, SubHandle, LoadMoreReq } from './types' type SubState = { events: NEvent[] eosedAt: number | null cachedEvents: NEvent[] filter: TSubRequestFilter urls: string[] onEvents: (events: NEvent[], eosed: boolean) => void onNew: (evt: NEvent) => void needSort: boolean subCloser: () => void flushTimer: ReturnType | null lastFlushedCount: number } export type TimelineActorConfig = { eventCacheActor: EventCacheActor pool: SimplePool getSigner: () => ISigner | undefined trackEventSeenOn: (id: string, relay: AbstractRelay) => void addEventToCache: (event: NEvent) => void } export type TimelineActor = { subscribe: Func loadMore: Func injectEvent: Inbox lc: Lifecycle } function generateTimelineKey(urls: string[], filter: Filter): string { const stableFilter: Record = {} Object.entries(filter) .sort() .forEach(([key, value]) => { if (key === 'limit') return if (Array.isArray(value)) { stableFilter[key] = [...value].sort() } stableFilter[key] = value }) const paramsStr = JSON.stringify({ urls: [...urls].sort(), filter: stableFilter }) const data = new TextEncoder().encode(paramsStr) const hashBuffer = sha256(data) return Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, '0')) .join('') } export function createTimelineActor(config: TimelineActorConfig): TimelineActor { const timelines: Record = {} const activeSubs = new Map() const subscribeCh = new Func() const loadMoreCh = new Func() const eventArrived = new Inbox<{ key: string; event: NEvent }>(0) const eoseArrived = new Inbox<{ key: string; eosed: boolean }>(0) const flushRequested = new Inbox(0) const injectEvent = new Inbox(0) const lc = new Lifecycle() function processEvent(key: string, event: NEvent, sub: SubState) { config.addEventToCache(event) if (sub.eosedAt === null) { sub.events.push(event) if (sub.flushTimer === null) { sub.flushTimer = setTimeout(() => flushRequested.send(key), 150) } return } if (event.created_at > sub.eosedAt) { sub.onNew(event) } const timeline = timelines[key] if (!timeline || !timeline.refs.length) return let idx = 0 for (const ref of timeline.refs) { if ( event.created_at > ref[1] || (event.created_at === ref[1] && event.id < ref[0]) ) { break } if (event.created_at === ref[1] && event.id === ref[0]) return idx++ } if (idx >= timeline.refs.length) return timeline.refs.splice(idx, 0, [event.id, event.created_at]) } spawn(lc, async () => { await loop( lc.on(() => { for (const [, sub] of activeSubs) { if (sub.flushTimer !== null) clearTimeout(sub.flushTimer) sub.subCloser() } return STOP }), subscribeCh.on(async ({ req, reply }) => { const { urls, filter, onEvents, onNew, startLogin, needSort = true } = req const relays = Array.from(new Set(urls)) const key = generateTimelineKey(relays, filter) let cachedEvents: NEvent[] = [] let since: number | undefined const existingTimeline = timelines[key] if (existingTimeline && existingTimeline.refs.length && needSort) { const ids = existingTimeline.refs.slice(0, filter.limit).map(([id]) => id) const looked = await config.eventCacheActor.lookupMany.call(ids) cachedEvents = looked.filter((evt): evt is NEvent => evt != null) if (cachedEvents.length) { onEvents([...cachedEvents], false) since = cachedEvents[0].created_at + 1 } } const adjustedFilter: Filter = since ? { ...filter, since } : filter const sub = createSubscription({ urls: relays, filters: [adjustedFilter], pool: config.pool, signer: config.getSigner(), startLogin, onevent: (evt) => eventArrived.send({ key, event: evt }), oneose: (eosed) => eoseArrived.send({ key, eosed }), onReceived: config.trackEventSeenOn }) const subState: SubState = { events: [], eosedAt: null, cachedEvents, filter, urls: relays, onEvents, onNew, needSort, subCloser: () => sub.close(), flushTimer: null, lastFlushedCount: 0 } activeSubs.set(key, subState) reply({ timelineKey: key, closer: () => { if (subState.flushTimer !== null) { clearTimeout(subState.flushTimer) subState.flushTimer = null } subState.onEvents = () => {} subState.onNew = () => {} sub.close() activeSubs.delete(key) } }) }), eventArrived.on(({ key, event }) => { const sub = activeSubs.get(key) if (!sub) return processEvent(key, event, sub) }), flushRequested.on((key) => { const sub = activeSubs.get(key) if (!sub) return sub.flushTimer = null if (sub.eosedAt !== null || sub.events.length === sub.lastFlushedCount) return sub.lastFlushedCount = sub.events.length const sorted = sub.needSort ? [...sub.events].sort((a, b) => b.created_at - a.created_at).slice(0, sub.filter.limit) : [...sub.events] const merged = sub.cachedEvents.length > 0 ? sorted.concat(sub.cachedEvents).slice(0, sub.filter.limit) : sorted sub.onEvents(merged, false) }), eoseArrived.on(({ key, eosed }) => { const sub = activeSubs.get(key) if (!sub) return if (sub.flushTimer !== null) { clearTimeout(sub.flushTimer) sub.flushTimer = null } if (eosed && sub.eosedAt === null) { sub.eosedAt = dayjs().unix() } if (!sub.needSort) { sub.onEvents([...sub.events], sub.eosedAt !== null) return } if (!eosed) { sub.events = sub.events .sort((a, b) => b.created_at - a.created_at) .slice(0, sub.filter.limit) sub.onEvents( [...sub.events.concat(sub.cachedEvents).slice(0, sub.filter.limit)], false ) return } sub.events = sub.events .sort((a, b) => b.created_at - a.created_at) .slice(0, sub.filter.limit) const timeline = timelines[key] if (!timeline || !timeline.refs.length) { timelines[key] = { refs: sub.events.map( (evt) => [evt.id, evt.created_at] as TTimelineRef ), filter: sub.filter, urls: sub.urls } sub.onEvents([...sub.events], true) return } const firstRefCreatedAt = timeline.refs[0][1] const newRefs = sub.events .filter((evt) => evt.created_at > firstRefCreatedAt) .map((evt) => [evt.id, evt.created_at] as TTimelineRef) if (sub.events.length >= sub.filter.limit) { timeline.refs = newRefs sub.onEvents([...sub.events], true) } else { timeline.refs = newRefs.concat(timeline.refs) sub.onEvents( [ ...sub.events .concat(sub.cachedEvents) .slice(0, sub.filter.limit) ], true ) } }), injectEvent.on((event) => { for (const [key, sub] of activeSubs) { if (!matchFilters([sub.filter], event)) continue processEvent(key, event, sub) } }), loadMoreCh.on(async ({ req: { key, until, limit }, reply }) => { const timeline = timelines[key] if (!timeline) { reply([]) return } const { filter, urls, refs } = timeline const startIdx = refs.findIndex(([, createdAt]) => createdAt <= until) const cachedIds = startIdx >= 0 ? refs.slice(startIdx, startIdx + limit).map(([id]) => id) : [] const cachedEvents = cachedIds.length > 0 ? ((await config.eventCacheActor.lookupMany.call(cachedIds)).filter( (e): e is NEvent => e != null ) as NEvent[]) : [] if (cachedEvents.length >= limit) { reply(cachedEvents) return } const adjustedUntil = cachedEvents.length ? cachedEvents[cachedEvents.length - 1].created_at - 1 : until const adjustedLimit = limit - cachedEvents.length const queryEvents = await new Promise((resolve) => { const events: NEvent[] = [] const sub = createSubscription({ urls, filters: [{ ...filter, until: adjustedUntil, limit: adjustedLimit }], pool: config.pool, signer: config.getSigner(), onevent: (evt) => { config.addEventToCache(evt) events.push(evt) }, oneose: (eosed) => { if (eosed) { sub.close() resolve(events) } }, onclose: () => resolve(events) }) }) const sorted = queryEvents .sort((a, b) => b.created_at - a.created_at) .slice(0, adjustedLimit) const lastRefCreatedAt = refs.length > 0 ? refs[refs.length - 1][1] : dayjs().unix() refs.push( ...sorted .filter((evt) => evt.created_at < lastRefCreatedAt) .map((evt) => [evt.id, evt.created_at] as TTimelineRef) ) reply([...cachedEvents, ...sorted]) }) ) }) return { subscribe: subscribeCh, loadMore: loadMoreCh, injectEvent, lc } }