diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index f58e872..94c26ac 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -4,6 +4,7 @@ import { SPAMMER_PERCENTILE_THRESHOLD } from '@/constants' import { useInfiniteScroll } from '@/hooks/useInfiniteScroll' import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event' import { tagNameEquals } from '@/lib/tag' +import { mergeTimelines } from '@/lib/timeline' import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' @@ -79,6 +80,7 @@ const NoteList = forwardRef< const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() + const [storedEvents, setStoredEvents] = useState([]) const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) const [initialLoading, setInitialLoading] = useState(true) @@ -135,7 +137,8 @@ const NoteList = forwardRef< const filteredEvents: Event[] = [] const keys: string[] = [] - events.forEach((evt) => { + const mergedEvents = mergeTimelines([events, storedEvents], LIMIT) + mergedEvents.forEach((evt) => { const key = getEventKey(evt) if (keySet.has(key)) return keySet.add(key) @@ -220,7 +223,7 @@ const NoteList = forwardRef< setFiltering(true) processEvents().finally(() => setFiltering(false)) - }, [events, shouldHideEvent, hideReplies, hideSpam, meetsMinTrustScore]) + }, [events, storedEvents, shouldHideEvent, hideReplies, hideSpam, meetsMinTrustScore]) useEffect(() => { const processNewEvents = async () => { @@ -279,12 +282,22 @@ const NoteList = forwardRef< async function init() { setInitialLoading(true) setEvents([]) + setStoredEvents([]) setNewEvents([]) if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { 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( subRequests.map(async ({ urls, filter }) => { const relays = urls.length ? urls : await client.determineRelaysByFilter(filter) diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index 698e5c2..b303602 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -1,6 +1,7 @@ import { BIG_RELAY_URLS, ExtendedKind, NOTIFICATION_LIST_STYLE } from '@/constants' import { useInfiniteScroll } from '@/hooks' import { compareEvents } from '@/lib/event' +import { mergeTimelines } from '@/lib/timeline' import { isTouchDevice } from '@/lib/utils' import { usePrimaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' @@ -44,7 +45,8 @@ const NotificationList = forwardRef((_, ref) => { const [refreshCount, setRefreshCount] = useState(0) const [timelineKey, setTimelineKey] = useState(undefined) const [initialLoading, setInitialLoading] = useState(true) - const [notifications, setNotifications] = useState([]) + const [storedEvents, setStoredEvents] = useState([]) + const [events, setEvents] = useState([]) const [until, setUntil] = useState(dayjs().unix()) const supportTouch = useMemo(() => isTouchDevice(), []) const topRef = useRef(null) @@ -91,7 +93,7 @@ const NotificationList = forwardRef((_, ref) => { const handleNewEvent = useCallback( (event: NostrEvent) => { if (event.pubkey === pubkey) return - setNotifications((oldEvents) => { + setEvents((oldEvents) => { const index = oldEvents.findIndex((oldEvent) => compareEvents(oldEvent, event) <= 0) if (index !== -1 && oldEvents[index].id === event.id) { return oldEvents @@ -117,26 +119,32 @@ const NotificationList = forwardRef((_, ref) => { const init = async () => { setInitialLoading(true) - setNotifications([]) + setStoredEvents([]) + setEvents([]) setRefreshCount(SHOW_COUNT) 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 { closer, timelineKey } = await client.subscribeTimeline( [ { urls: relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS, - filter: { - '#p': [pubkey], - kinds: filterKinds, - limit: LIMIT - } + filter } ], { onEvents: (events, eosed) => { if (events.length > 0) { - setNotifications(events.filter((event) => event.pubkey !== pubkey)) + setEvents(events) } if (eosed) { setInitialLoading(false) @@ -194,13 +202,23 @@ const NotificationList = forwardRef((_, ref) => { return false } - setNotifications((oldNotifications) => [ - ...oldNotifications, + setEvents((oldEvents) => [ + ...oldEvents, ...newEvents.filter((event) => event.pubkey !== pubkey) ]) setUntil(newEvents[newEvents.length - 1].created_at - 1) 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({ items: notifications, diff --git a/src/components/UserAggregationList/index.tsx b/src/components/UserAggregationList/index.tsx index 90f0773..90b9573 100644 --- a/src/components/UserAggregationList/index.tsx +++ b/src/components/UserAggregationList/index.tsx @@ -5,6 +5,7 @@ import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar' import Username, { SimpleUsername } from '@/components/Username' import { isMentioningMutedUsers } from '@/lib/event' import { toNote, toUserAggregationDetail } from '@/lib/link' +import { mergeTimelines } from '@/lib/timeline' import { cn, isTouchDevice } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useContentPolicy } from '@/providers/ContentPolicyProvider' @@ -75,6 +76,7 @@ const UserAggregationList = forwardRef< const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix()) + const [storedEvents, setStoredEvents] = useState([]) const [events, setEvents] = useState([]) const [filteredEvents, setFilteredEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) @@ -123,6 +125,7 @@ const UserAggregationList = forwardRef< async function init() { setLoading(true) + setStoredEvents([]) setEvents([]) setNewEvents([]) setHasMore(true) @@ -133,6 +136,15 @@ const UserAggregationList = forwardRef< 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( subRequests.map(async ({ urls, filter }) => { const relays = urls.length ? urls : await client.determineRelaysByFilter(filter) @@ -281,10 +293,11 @@ const UserAggregationList = forwardRef< }, [since]) useEffect(() => { - filterEvents(events).then((filtered) => { + const mergedEvents = mergeTimelines([events, storedEvents], LIMIT) + filterEvents(mergedEvents).then((filtered) => { setFilteredEvents(filtered) }) - }, [events, filterEvents]) + }, [events, storedEvents, filterEvents]) useEffect(() => { filterEvents(newEvents).then((filtered) => { diff --git a/src/lib/timeline.ts b/src/lib/timeline.ts new file mode 100644 index 0000000..7489416 --- /dev/null +++ b/src/lib/timeline.ts @@ -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 +} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 057df4d..9550b9a 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -9,6 +9,7 @@ import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { filterOutBigRelays } from '@/lib/relay' import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag' +import { mergeTimelines } from '@/lib/timeline' import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isSafari } from '@/lib/utils' 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('') } + 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( subRequests: { urls: string[]; filter: TSubRequestFilter }[], { @@ -317,6 +329,7 @@ class ClientService extends EventTarget { ) { const newEventIdSet = new Set() const requestCount = subRequests.length + const threshold = Math.floor(requestCount / 2) const timelines: NEvent[][] = new Array(requestCount).fill(0).map(() => []) let eosedCount = 0 @@ -332,8 +345,10 @@ class ClientService extends EventTarget { } timelines[i] = _events - const events = this.mergeTimelines(timelines, filter.limit) - onEvents(events, eosedCount >= requestCount) + if (eosedCount >= threshold) { + const events = mergeTimelines(timelines, filter.limit) + onEvents(events, eosedCount >= requestCount) + } }, onNew: (evt) => { 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) { const timeline = this.timelines[key] if (!timeline) return [] @@ -620,15 +590,6 @@ class ClientService extends EventTarget { onEvents([...cachedEvents], false) 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