feat: 💨

This commit is contained in:
codytseng 2026-01-02 02:40:13 +08:00
parent fd9f41c8f4
commit 167b6627f1
5 changed files with 124 additions and 72 deletions

View file

@ -4,6 +4,7 @@ import { SPAMMER_PERCENTILE_THRESHOLD } from '@/constants'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll' import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event' import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { mergeTimelines } from '@/lib/timeline'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
@ -79,6 +80,7 @@ const NoteList = forwardRef<
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const [storedEvents, setStoredEvents] = useState<Event[]>([])
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [initialLoading, setInitialLoading] = useState(true) const [initialLoading, setInitialLoading] = useState(true)
@ -135,7 +137,8 @@ const NoteList = forwardRef<
const filteredEvents: Event[] = [] const filteredEvents: Event[] = []
const keys: string[] = [] const keys: string[] = []
events.forEach((evt) => { const mergedEvents = mergeTimelines([events, storedEvents], LIMIT)
mergedEvents.forEach((evt) => {
const key = getEventKey(evt) const key = getEventKey(evt)
if (keySet.has(key)) return if (keySet.has(key)) return
keySet.add(key) keySet.add(key)
@ -220,7 +223,7 @@ const NoteList = forwardRef<
setFiltering(true) setFiltering(true)
processEvents().finally(() => setFiltering(false)) processEvents().finally(() => setFiltering(false))
}, [events, shouldHideEvent, hideReplies, hideSpam, meetsMinTrustScore]) }, [events, storedEvents, shouldHideEvent, hideReplies, hideSpam, meetsMinTrustScore])
useEffect(() => { useEffect(() => {
const processNewEvents = async () => { const processNewEvents = async () => {
@ -279,12 +282,22 @@ const NoteList = forwardRef<
async function init() { async function init() {
setInitialLoading(true) setInitialLoading(true)
setEvents([]) setEvents([])
setStoredEvents([])
setNewEvents([]) setNewEvents([])
if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
return () => {} return () => {}
} }
if (isPubkeyFeed) {
const storedEvents = await client.getEventsFromIndexed({
authors: subRequests.flatMap(({ filter }) => filter.authors ?? []),
kinds: showKinds,
limit: LIMIT
})
setStoredEvents(storedEvents)
}
const preprocessedSubRequests = await Promise.all( const preprocessedSubRequests = await Promise.all(
subRequests.map(async ({ urls, filter }) => { subRequests.map(async ({ urls, filter }) => {
const relays = urls.length ? urls : await client.determineRelaysByFilter(filter) const relays = urls.length ? urls : await client.determineRelaysByFilter(filter)

View file

@ -1,6 +1,7 @@
import { BIG_RELAY_URLS, ExtendedKind, NOTIFICATION_LIST_STYLE } from '@/constants' import { BIG_RELAY_URLS, ExtendedKind, NOTIFICATION_LIST_STYLE } from '@/constants'
import { useInfiniteScroll } from '@/hooks' import { useInfiniteScroll } from '@/hooks'
import { compareEvents } from '@/lib/event' import { compareEvents } from '@/lib/event'
import { mergeTimelines } from '@/lib/timeline'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -44,7 +45,8 @@ const NotificationList = forwardRef((_, ref) => {
const [refreshCount, setRefreshCount] = useState(0) const [refreshCount, setRefreshCount] = useState(0)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [initialLoading, setInitialLoading] = useState(true) const [initialLoading, setInitialLoading] = useState(true)
const [notifications, setNotifications] = useState<NostrEvent[]>([]) const [storedEvents, setStoredEvents] = useState<NostrEvent[]>([])
const [events, setEvents] = useState<NostrEvent[]>([])
const [until, setUntil] = useState<number | undefined>(dayjs().unix()) const [until, setUntil] = useState<number | undefined>(dayjs().unix())
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
const topRef = useRef<HTMLDivElement | null>(null) const topRef = useRef<HTMLDivElement | null>(null)
@ -91,7 +93,7 @@ const NotificationList = forwardRef((_, ref) => {
const handleNewEvent = useCallback( const handleNewEvent = useCallback(
(event: NostrEvent) => { (event: NostrEvent) => {
if (event.pubkey === pubkey) return if (event.pubkey === pubkey) return
setNotifications((oldEvents) => { setEvents((oldEvents) => {
const index = oldEvents.findIndex((oldEvent) => compareEvents(oldEvent, event) <= 0) const index = oldEvents.findIndex((oldEvent) => compareEvents(oldEvent, event) <= 0)
if (index !== -1 && oldEvents[index].id === event.id) { if (index !== -1 && oldEvents[index].id === event.id) {
return oldEvents return oldEvents
@ -117,26 +119,32 @@ const NotificationList = forwardRef((_, ref) => {
const init = async () => { const init = async () => {
setInitialLoading(true) setInitialLoading(true)
setNotifications([]) setStoredEvents([])
setEvents([])
setRefreshCount(SHOW_COUNT) setRefreshCount(SHOW_COUNT)
setLastReadTime(getNotificationsSeenAt()) setLastReadTime(getNotificationsSeenAt())
const filter = {
'#p': [pubkey],
kinds: filterKinds,
limit: LIMIT
}
const storedEvents = await client.getEventsFromIndexed(filter)
setStoredEvents(storedEvents)
const relayList = await client.fetchRelayList(pubkey) const relayList = await client.fetchRelayList(pubkey)
const { closer, timelineKey } = await client.subscribeTimeline( const { closer, timelineKey } = await client.subscribeTimeline(
[ [
{ {
urls: relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS, urls: relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS,
filter: { filter
'#p': [pubkey],
kinds: filterKinds,
limit: LIMIT
}
} }
], ],
{ {
onEvents: (events, eosed) => { onEvents: (events, eosed) => {
if (events.length > 0) { if (events.length > 0) {
setNotifications(events.filter((event) => event.pubkey !== pubkey)) setEvents(events)
} }
if (eosed) { if (eosed) {
setInitialLoading(false) setInitialLoading(false)
@ -194,13 +202,23 @@ const NotificationList = forwardRef((_, ref) => {
return false return false
} }
setNotifications((oldNotifications) => [ setEvents((oldEvents) => [
...oldNotifications, ...oldEvents,
...newEvents.filter((event) => event.pubkey !== pubkey) ...newEvents.filter((event) => event.pubkey !== pubkey)
]) ])
setUntil(newEvents[newEvents.length - 1].created_at - 1) setUntil(newEvents[newEvents.length - 1].created_at - 1)
return true return true
}, [timelineKey, until, pubkey, setNotifications, setUntil]) }, [timelineKey, until, pubkey, setEvents, setUntil])
const notifications = useMemo(() => {
return mergeTimelines(
[
events.filter((evt) => evt.pubkey !== pubkey),
storedEvents.filter((evt) => evt.pubkey !== pubkey)
],
LIMIT
)
}, [events, storedEvents, pubkey])
const { visibleItems, shouldShowLoadingIndicator, bottomRef, setShowCount } = useInfiniteScroll({ const { visibleItems, shouldShowLoadingIndicator, bottomRef, setShowCount } = useInfiniteScroll({
items: notifications, items: notifications,

View file

@ -5,6 +5,7 @@ import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar'
import Username, { SimpleUsername } from '@/components/Username' import Username, { SimpleUsername } from '@/components/Username'
import { isMentioningMutedUsers } from '@/lib/event' import { isMentioningMutedUsers } from '@/lib/event'
import { toNote, toUserAggregationDetail } from '@/lib/link' import { toNote, toUserAggregationDetail } from '@/lib/link'
import { mergeTimelines } from '@/lib/timeline'
import { cn, isTouchDevice } from '@/lib/utils' import { cn, isTouchDevice } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
@ -75,6 +76,7 @@ const UserAggregationList = forwardRef<
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix()) const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
const [storedEvents, setStoredEvents] = useState<Event[]>([])
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [filteredEvents, setFilteredEvents] = useState<Event[]>([]) const [filteredEvents, setFilteredEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
@ -123,6 +125,7 @@ const UserAggregationList = forwardRef<
async function init() { async function init() {
setLoading(true) setLoading(true)
setStoredEvents([])
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])
setHasMore(true) setHasMore(true)
@ -133,6 +136,15 @@ const UserAggregationList = forwardRef<
return () => {} return () => {}
} }
if (isPubkeyFeed) {
const storedEvents = await client.getEventsFromIndexed({
authors: subRequests.flatMap(({ filter }) => filter.authors ?? []),
kinds: showKinds ?? [],
since: dayjs().subtract(1, 'day').unix()
})
setStoredEvents(storedEvents)
}
const preprocessedSubRequests = await Promise.all( const preprocessedSubRequests = await Promise.all(
subRequests.map(async ({ urls, filter }) => { subRequests.map(async ({ urls, filter }) => {
const relays = urls.length ? urls : await client.determineRelaysByFilter(filter) const relays = urls.length ? urls : await client.determineRelaysByFilter(filter)
@ -281,10 +293,11 @@ const UserAggregationList = forwardRef<
}, [since]) }, [since])
useEffect(() => { useEffect(() => {
filterEvents(events).then((filtered) => { const mergedEvents = mergeTimelines([events, storedEvents], LIMIT)
filterEvents(mergedEvents).then((filtered) => {
setFilteredEvents(filtered) setFilteredEvents(filtered)
}) })
}, [events, filterEvents]) }, [events, storedEvents, filterEvents])
useEffect(() => { useEffect(() => {
filterEvents(newEvents).then((filtered) => { filterEvents(newEvents).then((filtered) => {

47
src/lib/timeline.ts Normal file
View file

@ -0,0 +1,47 @@
import { NostrEvent } from 'nostr-tools'
import { compareEvents } from './event'
export function mergeTimelines(timelines: NostrEvent[][], limit: number) {
if (timelines.length === 0) return []
if (timelines.length === 1) return [...timelines[0]]
return timelines.reduce((merged, current) => _mergeTimelines(merged, current, limit), [])
}
function _mergeTimelines(a: NostrEvent[], b: NostrEvent[], limit: number): NostrEvent[] {
if (a.length === 0) return [...b]
if (b.length === 0) return [...a]
const result: NostrEvent[] = []
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++
}
}
if (result.length >= limit) {
return result
}
while (i < a.length && result.length < limit) {
result.push(a[i])
i++
}
while (j < b.length && result.length < limit) {
result.push(b[j])
j++
}
return result
}

View file

@ -9,6 +9,7 @@ import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata
import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { filterOutBigRelays } from '@/lib/relay' import { filterOutBigRelays } from '@/lib/relay'
import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag'
import { mergeTimelines } from '@/lib/timeline'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
import { isSafari } from '@/lib/utils' import { isSafari } from '@/lib/utils'
import { ISigner, TProfile, TPublishOptions, TRelayList, TSubRequestFilter } from '@/types' import { ISigner, TProfile, TPublishOptions, TRelayList, TSubRequestFilter } from '@/types'
@ -294,6 +295,17 @@ class ClientService extends EventTarget {
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
} }
async getEventsFromIndexed(filter: Filter) {
const items = await indexedDb.getEvents(filter)
const storedEvents: NEvent[] = []
items.forEach((item) => {
storedEvents.push(item.event)
this.trackEventExternalSeenOn(item.event.id, item.relays)
this.addEventToCache(item.event)
})
return storedEvents
}
async subscribeTimeline( async subscribeTimeline(
subRequests: { urls: string[]; filter: TSubRequestFilter }[], subRequests: { urls: string[]; filter: TSubRequestFilter }[],
{ {
@ -317,6 +329,7 @@ class ClientService extends EventTarget {
) { ) {
const newEventIdSet = new Set<string>() const newEventIdSet = new Set<string>()
const requestCount = subRequests.length const requestCount = subRequests.length
const threshold = Math.floor(requestCount / 2)
const timelines: NEvent[][] = new Array(requestCount).fill(0).map(() => []) const timelines: NEvent[][] = new Array(requestCount).fill(0).map(() => [])
let eosedCount = 0 let eosedCount = 0
@ -332,8 +345,10 @@ class ClientService extends EventTarget {
} }
timelines[i] = _events timelines[i] = _events
const events = this.mergeTimelines(timelines, filter.limit) if (eosedCount >= threshold) {
onEvents(events, eosedCount >= requestCount) const events = mergeTimelines(timelines, filter.limit)
onEvents(events, eosedCount >= requestCount)
}
}, },
onNew: (evt) => { onNew: (evt) => {
if (newEventIdSet.has(evt.id)) return if (newEventIdSet.has(evt.id)) return
@ -362,51 +377,6 @@ class ClientService extends EventTarget {
} }
} }
private mergeTimelines(timelines: NEvent[][], limit: number) {
if (timelines.length === 0) return []
if (timelines.length === 1) return timelines[0].slice(0, limit)
return timelines.reduce((merged, current) => this._mergeTimelines(merged, current, limit), [])
}
private _mergeTimelines(a: NEvent[], b: NEvent[], limit: number): NEvent[] {
if (a.length === 0) return b.slice(0, limit)
if (b.length === 0) return a.slice(0, limit)
const result: NEvent[] = []
let i = 0
let j = 0
while (i < a.length && j < b.length && result.length < limit) {
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++
}
}
if (result.length >= limit) {
return result
}
while (i < a.length) {
result.push(a[i])
i++
}
while (j < b.length) {
result.push(b[j])
j++
}
return result
}
async loadMoreTimeline(key: string, until: number, limit: number) { async loadMoreTimeline(key: string, until: number, limit: number) {
const timeline = this.timelines[key] const timeline = this.timelines[key]
if (!timeline) return [] if (!timeline) return []
@ -620,15 +590,6 @@ class ClientService extends EventTarget {
onEvents([...cachedEvents], false) onEvents([...cachedEvents], false)
since = cachedEvents[0].created_at + 1 since = cachedEvents[0].created_at + 1
} }
} else if (needSaveToDb) {
const storedEvents: NEvent[] = []
const items = await indexedDb.getEvents(filter)
items.forEach((item) => {
this.trackEventExternalSeenOn(item.event.id, item.relays)
storedEvents.push(item.event)
this.addEventToCache(item.event)
})
onEvents([...storedEvents], false)
} }
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias