feat: add persistent cache for following feed and notifications
This commit is contained in:
parent
7a9c777744
commit
fd9f41c8f4
10 changed files with 268 additions and 102 deletions
|
|
@ -34,11 +34,11 @@ export default function Nip05({ pubkey, append }: { pubkey: string; append?: str
|
||||||
{nip05IsVerified ? (
|
{nip05IsVerified ? (
|
||||||
<Favicon
|
<Favicon
|
||||||
domain={nip05Domain}
|
domain={nip05Domain}
|
||||||
className="w-3.5 h-3.5 rounded-full"
|
className="w-3.5 h-3.5 rounded-full shrink-0"
|
||||||
fallback={<BadgeCheck className="text-primary" />}
|
fallback={<BadgeCheck className="text-primary shrink-0" />}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BadgeAlert className="text-muted-foreground" />
|
<BadgeAlert className="text-muted-foreground shrink-0" />
|
||||||
)}
|
)}
|
||||||
<SecondaryPageLink
|
<SecondaryPageLink
|
||||||
to={toNoteList({ domain: nip05Domain })}
|
to={toNoteList({ domain: nip05Domain })}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ export default function NormalFeed({
|
||||||
isMainFeed = false,
|
isMainFeed = false,
|
||||||
showRelayCloseReason = false,
|
showRelayCloseReason = false,
|
||||||
disable24hMode = false,
|
disable24hMode = false,
|
||||||
onRefresh
|
onRefresh,
|
||||||
|
isPubkeyFeed = false
|
||||||
}: {
|
}: {
|
||||||
subRequests: TFeedSubRequest[]
|
subRequests: TFeedSubRequest[]
|
||||||
areAlgoRelays?: boolean
|
areAlgoRelays?: boolean
|
||||||
|
|
@ -24,6 +25,7 @@ export default function NormalFeed({
|
||||||
showRelayCloseReason?: boolean
|
showRelayCloseReason?: boolean
|
||||||
disable24hMode?: boolean
|
disable24hMode?: boolean
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
|
isPubkeyFeed?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { showKinds } = useKindFilter()
|
const { showKinds } = useKindFilter()
|
||||||
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
||||||
|
|
@ -102,6 +104,7 @@ export default function NormalFeed({
|
||||||
subRequests={subRequests}
|
subRequests={subRequests}
|
||||||
areAlgoRelays={areAlgoRelays}
|
areAlgoRelays={areAlgoRelays}
|
||||||
showRelayCloseReason={showRelayCloseReason}
|
showRelayCloseReason={showRelayCloseReason}
|
||||||
|
isPubkeyFeed={isPubkeyFeed}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NoteList
|
<NoteList
|
||||||
|
|
@ -111,6 +114,7 @@ export default function NormalFeed({
|
||||||
hideReplies={listMode === 'posts'}
|
hideReplies={listMode === 'posts'}
|
||||||
areAlgoRelays={areAlgoRelays}
|
areAlgoRelays={areAlgoRelays}
|
||||||
showRelayCloseReason={showRelayCloseReason}
|
showRelayCloseReason={showRelayCloseReason}
|
||||||
|
isPubkeyFeed={isPubkeyFeed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { LoadingBar } from '../LoadingBar'
|
||||||
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
|
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
|
||||||
import PinnedNoteCard from '../PinnedNoteCard'
|
import PinnedNoteCard from '../PinnedNoteCard'
|
||||||
|
|
||||||
|
|
@ -53,6 +54,7 @@ const NoteList = forwardRef<
|
||||||
pinnedEventIds?: string[]
|
pinnedEventIds?: string[]
|
||||||
filterFn?: (event: Event) => boolean
|
filterFn?: (event: Event) => boolean
|
||||||
showNewNotesDirectly?: boolean
|
showNewNotesDirectly?: boolean
|
||||||
|
isPubkeyFeed?: boolean
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
|
|
@ -66,7 +68,8 @@ const NoteList = forwardRef<
|
||||||
showRelayCloseReason = false,
|
showRelayCloseReason = false,
|
||||||
pinnedEventIds,
|
pinnedEventIds,
|
||||||
filterFn,
|
filterFn,
|
||||||
showNewNotesDirectly = false
|
showNewNotesDirectly = false,
|
||||||
|
isPubkeyFeed = false
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -78,7 +81,7 @@ const NoteList = forwardRef<
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
const [initialLoading, setInitialLoading] = useState(false)
|
const [initialLoading, setInitialLoading] = useState(true)
|
||||||
const [filtering, setFiltering] = useState(false)
|
const [filtering, setFiltering] = useState(false)
|
||||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||||
const [filteredNotes, setFilteredNotes] = useState<
|
const [filteredNotes, setFilteredNotes] = useState<
|
||||||
|
|
@ -340,7 +343,8 @@ const NoteList = forwardRef<
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
startLogin,
|
startLogin,
|
||||||
needSort: !areAlgoRelays
|
needSort: !areAlgoRelays,
|
||||||
|
needSaveToDb: isPubkeyFeed
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
setTimelineKey(timelineKey)
|
setTimelineKey(timelineKey)
|
||||||
|
|
@ -384,6 +388,7 @@ const NoteList = forwardRef<
|
||||||
|
|
||||||
const list = (
|
const list = (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
|
{initialLoading && shouldShowLoadingIndicator && <LoadingBar />}
|
||||||
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
||||||
{visibleItems.map(({ key, event, reposters }) => (
|
{visibleItems.map(({ key, event, reposters }) => (
|
||||||
<NoteCard
|
<NoteCard
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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 { compareEvents } from '@/lib/event'
|
import { compareEvents } from '@/lib/event'
|
||||||
import { isTouchDevice } from '@/lib/utils'
|
import { isTouchDevice } from '@/lib/utils'
|
||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
|
@ -22,6 +23,7 @@ import {
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||||
|
import { LoadingBar } from '../LoadingBar'
|
||||||
import { RefreshButton } from '../RefreshButton'
|
import { RefreshButton } from '../RefreshButton'
|
||||||
import Tabs from '../Tabs'
|
import Tabs from '../Tabs'
|
||||||
import { NotificationItem } from './NotificationItem'
|
import { NotificationItem } from './NotificationItem'
|
||||||
|
|
@ -41,14 +43,11 @@ const NotificationList = forwardRef((_, ref) => {
|
||||||
const [lastReadTime, setLastReadTime] = useState(0)
|
const [lastReadTime, setLastReadTime] = useState(0)
|
||||||
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 [loading, setLoading] = useState(true)
|
const [initialLoading, setInitialLoading] = useState(true)
|
||||||
const [notifications, setNotifications] = useState<NostrEvent[]>([])
|
const [notifications, setNotifications] = useState<NostrEvent[]>([])
|
||||||
const [visibleNotifications, setVisibleNotifications] = useState<NostrEvent[]>([])
|
|
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
|
||||||
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)
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const filterKinds = useMemo(() => {
|
const filterKinds = useMemo(() => {
|
||||||
switch (notificationType) {
|
switch (notificationType) {
|
||||||
case 'mentions':
|
case 'mentions':
|
||||||
|
|
@ -82,11 +81,11 @@ const NotificationList = forwardRef((_, ref) => {
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
if (loading) return
|
if (initialLoading) return
|
||||||
setRefreshCount((count) => count + 1)
|
setRefreshCount((count) => count + 1)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[loading]
|
[initialLoading]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleNewEvent = useCallback(
|
const handleNewEvent = useCallback(
|
||||||
|
|
@ -117,9 +116,9 @@ const NotificationList = forwardRef((_, ref) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
setLoading(true)
|
setInitialLoading(true)
|
||||||
setNotifications([])
|
setNotifications([])
|
||||||
setShowCount(SHOW_COUNT)
|
setRefreshCount(SHOW_COUNT)
|
||||||
setLastReadTime(getNotificationsSeenAt())
|
setLastReadTime(getNotificationsSeenAt())
|
||||||
const relayList = await client.fetchRelayList(pubkey)
|
const relayList = await client.fetchRelayList(pubkey)
|
||||||
|
|
||||||
|
|
@ -140,7 +139,7 @@ const NotificationList = forwardRef((_, ref) => {
|
||||||
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
||||||
}
|
}
|
||||||
if (eosed) {
|
if (eosed) {
|
||||||
setLoading(false)
|
setInitialLoading(false)
|
||||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
||||||
threadService.addRepliesToThread(events)
|
threadService.addRepliesToThread(events)
|
||||||
stuffStatsService.updateStuffStatsByEvents(events)
|
stuffStatsService.updateStuffStatsByEvents(events)
|
||||||
|
|
@ -150,7 +149,8 @@ const NotificationList = forwardRef((_, ref) => {
|
||||||
handleNewEvent(event)
|
handleNewEvent(event)
|
||||||
threadService.addRepliesToThread([event])
|
threadService.addRepliesToThread([event])
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ needSaveToDb: true }
|
||||||
)
|
)
|
||||||
setTimelineKey(timelineKey)
|
setTimelineKey(timelineKey)
|
||||||
return closer
|
return closer
|
||||||
|
|
@ -187,63 +187,27 @@ const NotificationList = forwardRef((_, ref) => {
|
||||||
}
|
}
|
||||||
}, [pubkey, active, filterKinds, handleNewEvent])
|
}, [pubkey, active, filterKinds, handleNewEvent])
|
||||||
|
|
||||||
useEffect(() => {
|
const handleLoadMore = useCallback(async () => {
|
||||||
setVisibleNotifications(notifications.slice(0, showCount))
|
if (!timelineKey || !until) return false
|
||||||
}, [notifications, showCount])
|
const newEvents = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||||
|
if (newEvents.length === 0) {
|
||||||
useEffect(() => {
|
return false
|
||||||
const options = {
|
|
||||||
root: null,
|
|
||||||
rootMargin: '10px',
|
|
||||||
threshold: 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMore = async () => {
|
|
||||||
if (showCount < notifications.length) {
|
|
||||||
setShowCount((count) => count + SHOW_COUNT)
|
|
||||||
// preload more
|
|
||||||
if (notifications.length - showCount > LIMIT / 2) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pubkey || !timelineKey || !until || loading) return
|
|
||||||
setLoading(true)
|
|
||||||
const newNotifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
|
||||||
setLoading(false)
|
|
||||||
if (newNotifications.length === 0) {
|
|
||||||
setUntil(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newNotifications.length > 0) {
|
|
||||||
setNotifications((oldNotifications) => [
|
setNotifications((oldNotifications) => [
|
||||||
...oldNotifications,
|
...oldNotifications,
|
||||||
...newNotifications.filter((event) => event.pubkey !== pubkey)
|
...newEvents.filter((event) => event.pubkey !== pubkey)
|
||||||
])
|
])
|
||||||
}
|
setUntil(newEvents[newEvents.length - 1].created_at - 1)
|
||||||
|
return true
|
||||||
|
}, [timelineKey, until, pubkey, setNotifications, setUntil])
|
||||||
|
|
||||||
setUntil(newNotifications[newNotifications.length - 1].created_at - 1)
|
const { visibleItems, shouldShowLoadingIndicator, bottomRef, setShowCount } = useInfiniteScroll({
|
||||||
}
|
items: notifications,
|
||||||
|
showCount: SHOW_COUNT,
|
||||||
const observerInstance = new IntersectionObserver((entries) => {
|
onLoadMore: handleLoadMore,
|
||||||
if (entries[0].isIntersecting) {
|
initialLoading
|
||||||
loadMore()
|
})
|
||||||
}
|
|
||||||
}, options)
|
|
||||||
|
|
||||||
const currentBottomRef = bottomRef.current
|
|
||||||
|
|
||||||
if (currentBottomRef) {
|
|
||||||
observerInstance.observe(currentBottomRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (observerInstance && currentBottomRef) {
|
|
||||||
observerInstance.unobserve(currentBottomRef)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [pubkey, timelineKey, until, loading, showCount, notifications])
|
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' })
|
topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' })
|
||||||
|
|
@ -253,19 +217,20 @@ const NotificationList = forwardRef((_, ref) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = (
|
const list = (
|
||||||
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}>
|
<div>
|
||||||
{visibleNotifications.map((notification) => (
|
{initialLoading && shouldShowLoadingIndicator && <LoadingBar />}
|
||||||
|
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'mb-2' : ''} />
|
||||||
|
{visibleItems.map((notification) => (
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
isNew={notification.created_at > lastReadTime}
|
isNew={notification.created_at > lastReadTime}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
{until || loading ? (
|
{!!until || shouldShowLoadingIndicator ? (
|
||||||
<div ref={bottomRef}>
|
|
||||||
<NotificationSkeleton />
|
<NotificationSkeleton />
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
t('no more notifications')
|
t('no more notifications')
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const UserAggregationList = forwardRef<
|
||||||
filterMutedNotes?: boolean
|
filterMutedNotes?: boolean
|
||||||
areAlgoRelays?: boolean
|
areAlgoRelays?: boolean
|
||||||
showRelayCloseReason?: boolean
|
showRelayCloseReason?: boolean
|
||||||
|
isPubkeyFeed?: boolean
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
|
|
@ -60,7 +61,8 @@ const UserAggregationList = forwardRef<
|
||||||
showKinds,
|
showKinds,
|
||||||
filterMutedNotes = true,
|
filterMutedNotes = true,
|
||||||
areAlgoRelays = false,
|
areAlgoRelays = false,
|
||||||
showRelayCloseReason = false
|
showRelayCloseReason = false,
|
||||||
|
isPubkeyFeed = false
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -187,7 +189,8 @@ const UserAggregationList = forwardRef<
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
startLogin,
|
startLogin,
|
||||||
needSort: !areAlgoRelays
|
needSort: !areAlgoRelays,
|
||||||
|
needSaveToDb: isPubkeyFeed
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
setTimelineKey(timelineKey)
|
setTimelineKey(timelineKey)
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,6 @@ export function useInfiniteScroll<T>({
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
const { loading, hasMore, showCount, itemsLength, initialLoading } = stateRef.current
|
const { loading, hasMore, showCount, itemsLength, initialLoading } = stateRef.current
|
||||||
|
|
||||||
if (initialLoading || loading) return
|
|
||||||
|
|
||||||
// If there are more items to show, increase showCount first
|
// If there are more items to show, increase showCount first
|
||||||
if (showCount < itemsLength) {
|
if (showCount < itemsLength) {
|
||||||
setShowCount((prev) => prev + initialShowCount)
|
setShowCount((prev) => prev + initialShowCount)
|
||||||
|
|
@ -76,6 +74,8 @@ export function useInfiniteScroll<T>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initialLoading || loading) return
|
||||||
|
|
||||||
if (!hasMore) return
|
if (!hasMore) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const newHasMore = await onLoadMore()
|
const newHasMore = await onLoadMore()
|
||||||
|
|
@ -114,6 +114,7 @@ export function useInfiniteScroll<T>({
|
||||||
shouldShowLoadingIndicator,
|
shouldShowLoadingIndicator,
|
||||||
bottomRef,
|
bottomRef,
|
||||||
setHasMore,
|
setHasMore,
|
||||||
setLoading
|
setLoading,
|
||||||
|
setShowCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export default function FollowingFeed() {
|
||||||
setRefreshCount((count) => count + 1)
|
setRefreshCount((count) => count + 1)
|
||||||
}}
|
}}
|
||||||
isMainFeed
|
isMainFeed
|
||||||
|
isPubkeyFeed
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,5 +28,5 @@ export default function PinnedFeed() {
|
||||||
init()
|
init()
|
||||||
}, [pubkey, pinnedPubkeySet])
|
}, [pubkey, pinnedPubkeySet])
|
||||||
|
|
||||||
return <NormalFeed subRequests={subRequests} isMainFeed />
|
return <NormalFeed subRequests={subRequests} isMainFeed isPubkeyFeed />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class ClientService extends EventTarget {
|
||||||
pubkey?: string
|
pubkey?: string
|
||||||
currentRelays: string[] = []
|
currentRelays: string[] = []
|
||||||
private pool: SimplePool
|
private pool: SimplePool
|
||||||
|
private externalSeenOn = new Map<string, Set<string>>()
|
||||||
|
|
||||||
private timelines: Record<
|
private timelines: Record<
|
||||||
string,
|
string,
|
||||||
|
|
@ -306,20 +307,21 @@ class ClientService extends EventTarget {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
startLogin,
|
startLogin,
|
||||||
needSort = true
|
needSort = true,
|
||||||
|
needSaveToDb = false
|
||||||
}: {
|
}: {
|
||||||
startLogin?: () => void
|
startLogin?: () => void
|
||||||
needSort?: boolean
|
needSort?: boolean
|
||||||
|
needSaveToDb?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
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(() => [])
|
||||||
let events: NEvent[] = []
|
|
||||||
let eosedCount = 0
|
let eosedCount = 0
|
||||||
|
|
||||||
const subs = await Promise.all(
|
const subs = await Promise.all(
|
||||||
subRequests.map(({ urls, filter }) => {
|
subRequests.map(({ urls, filter }, i) => {
|
||||||
return this._subscribeTimeline(
|
return this._subscribeTimeline(
|
||||||
urls,
|
urls,
|
||||||
filter,
|
filter,
|
||||||
|
|
@ -329,11 +331,9 @@ class ClientService extends EventTarget {
|
||||||
eosedCount++
|
eosedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
events = this.mergeTimelines(events, _events)
|
timelines[i] = _events
|
||||||
|
const events = this.mergeTimelines(timelines, filter.limit)
|
||||||
if (eosedCount >= threshold) {
|
|
||||||
onEvents(events, eosedCount >= requestCount)
|
onEvents(events, eosedCount >= requestCount)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onNew: (evt) => {
|
onNew: (evt) => {
|
||||||
if (newEventIdSet.has(evt.id)) return
|
if (newEventIdSet.has(evt.id)) return
|
||||||
|
|
@ -342,7 +342,7 @@ class ClientService extends EventTarget {
|
||||||
},
|
},
|
||||||
onClose
|
onClose
|
||||||
},
|
},
|
||||||
{ startLogin, needSort }
|
{ startLogin, needSort, needSaveToDb }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
@ -362,14 +362,20 @@ class ClientService extends EventTarget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mergeTimelines(a: NEvent[], b: NEvent[]): NEvent[] {
|
private mergeTimelines(timelines: NEvent[][], limit: number) {
|
||||||
if (a.length === 0) return [...b]
|
if (timelines.length === 0) return []
|
||||||
if (b.length === 0) return [...a]
|
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[] = []
|
const result: NEvent[] = []
|
||||||
let i = 0
|
let i = 0
|
||||||
let j = 0
|
let j = 0
|
||||||
while (i < a.length && j < b.length) {
|
while (i < a.length && j < b.length && result.length < limit) {
|
||||||
const cmp = compareEvents(a[i], b[j])
|
const cmp = compareEvents(a[i], b[j])
|
||||||
if (cmp > 0) {
|
if (cmp > 0) {
|
||||||
result.push(a[i])
|
result.push(a[i])
|
||||||
|
|
@ -384,6 +390,20 @@ class ClientService extends EventTarget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -579,10 +599,12 @@ class ClientService extends EventTarget {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
startLogin,
|
startLogin,
|
||||||
needSort = true
|
needSort = true,
|
||||||
|
needSaveToDb = false
|
||||||
}: {
|
}: {
|
||||||
startLogin?: () => void
|
startLogin?: () => void
|
||||||
needSort?: boolean
|
needSort?: boolean
|
||||||
|
needSaveToDb?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const relays = Array.from(new Set(urls))
|
const relays = Array.from(new Set(urls))
|
||||||
|
|
@ -598,6 +620,15 @@ 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
|
||||||
|
|
@ -615,6 +646,9 @@ class ClientService extends EventTarget {
|
||||||
// new event
|
// new event
|
||||||
if (evt.created_at > eosedAt) {
|
if (evt.created_at > eosedAt) {
|
||||||
onNew(evt)
|
onNew(evt)
|
||||||
|
if (needSaveToDb) {
|
||||||
|
indexedDb.putEvents([{ event: evt, relays: that.getEventHints(evt.id) }])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeline = that.timelines[key]
|
const timeline = that.timelines[key]
|
||||||
|
|
@ -654,6 +688,11 @@ class ClientService extends EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
||||||
|
if (needSaveToDb) {
|
||||||
|
indexedDb.putEvents(
|
||||||
|
events.map((evt) => ({ event: evt, relays: this.getEventHints(evt.id) }))
|
||||||
|
)
|
||||||
|
}
|
||||||
const timeline = that.timelines[key]
|
const timeline = that.timelines[key]
|
||||||
// no cache yet
|
// no cache yet
|
||||||
if (!timeline || Array.isArray(timeline) || !timeline.refs.length) {
|
if (!timeline || Array.isArray(timeline) || !timeline.refs.length) {
|
||||||
|
|
@ -675,6 +714,9 @@ class ClientService extends EventTarget {
|
||||||
// if new refs are more than limit, means old refs are too old, replace them
|
// if new refs are more than limit, means old refs are too old, replace them
|
||||||
timeline.refs = newRefs
|
timeline.refs = newRefs
|
||||||
onEvents([...events], true)
|
onEvents([...events], true)
|
||||||
|
if (needSaveToDb) {
|
||||||
|
indexedDb.deleteEvents({ ...filter, until: events[events.length - 1].created_at })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// merge new refs with old refs
|
// merge new refs with old refs
|
||||||
timeline.refs = newRefs.concat(timeline.refs)
|
timeline.refs = newRefs.concat(timeline.refs)
|
||||||
|
|
@ -737,7 +779,12 @@ class ClientService extends EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
getSeenEventRelayUrls(eventId: string) {
|
getSeenEventRelayUrls(eventId: string) {
|
||||||
return this.getSeenEventRelays(eventId).map((relay) => relay.url)
|
return Array.from(
|
||||||
|
new Set([
|
||||||
|
...this.getSeenEventRelays(eventId).map((relay) => relay.url),
|
||||||
|
...(this.externalSeenOn.get(eventId) || [])
|
||||||
|
])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getEventHints(eventId: string) {
|
getEventHints(eventId: string) {
|
||||||
|
|
@ -757,6 +804,15 @@ class ClientService extends EventTarget {
|
||||||
set.add(relay)
|
set.add(relay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackEventExternalSeenOn(eventId: string, relayUrls: string[]) {
|
||||||
|
let set = this.externalSeenOn.get(eventId)
|
||||||
|
if (!set) {
|
||||||
|
set = new Set()
|
||||||
|
this.externalSeenOn.set(eventId, set)
|
||||||
|
}
|
||||||
|
relayUrls.forEach((url) => set.add(url))
|
||||||
|
}
|
||||||
|
|
||||||
private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) {
|
private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) {
|
||||||
return await new Promise<NEvent[]>((resolve) => {
|
return await new Promise<NEvent[]>((resolve) => {
|
||||||
const events: NEvent[] = []
|
const events: NEvent[] = []
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { TRelayInfo } from '@/types'
|
import { TRelayInfo } from '@/types'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import dayjs from 'dayjs'
|
||||||
|
import { Event, Filter, kinds, matchFilter } from 'nostr-tools'
|
||||||
|
|
||||||
type TValue<T = any> = {
|
type TValue<T = any> = {
|
||||||
key: string
|
key: string
|
||||||
|
|
@ -25,6 +26,7 @@ const StoreNames = {
|
||||||
RELAY_INFOS: 'relayInfos',
|
RELAY_INFOS: 'relayInfos',
|
||||||
DECRYPTED_CONTENTS: 'decryptedContents',
|
DECRYPTED_CONTENTS: 'decryptedContents',
|
||||||
PINNED_USERS_EVENTS: 'pinnedUsersEvents',
|
PINNED_USERS_EVENTS: 'pinnedUsersEvents',
|
||||||
|
EVENTS: 'events',
|
||||||
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated
|
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated
|
||||||
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
|
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +47,7 @@ class IndexedDbService {
|
||||||
init(): Promise<void> {
|
init(): Promise<void> {
|
||||||
if (!this.initPromise) {
|
if (!this.initPromise) {
|
||||||
this.initPromise = new Promise((resolve, reject) => {
|
this.initPromise = new Promise((resolve, reject) => {
|
||||||
const request = window.indexedDB.open('jumble', 10)
|
const request = window.indexedDB.open('jumble', 11)
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
reject(event)
|
reject(event)
|
||||||
|
|
@ -103,6 +105,12 @@ class IndexedDbService {
|
||||||
if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) {
|
if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) {
|
||||||
db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' })
|
db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' })
|
||||||
}
|
}
|
||||||
|
if (!db.objectStoreNames.contains(StoreNames.EVENTS)) {
|
||||||
|
const feedEventsStore = db.createObjectStore(StoreNames.EVENTS, {
|
||||||
|
keyPath: 'event.id'
|
||||||
|
})
|
||||||
|
feedEventsStore.createIndex('createdAtIndex', 'event.created_at')
|
||||||
|
}
|
||||||
|
|
||||||
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
|
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
|
||||||
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
|
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
|
||||||
|
|
@ -113,7 +121,10 @@ class IndexedDbService {
|
||||||
this.db = db
|
this.db = db
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
|
setTimeout(() => {
|
||||||
|
this.cleanUpOldEvents()
|
||||||
|
this.cleanUp()
|
||||||
|
}, 1000 * 30) // 30 seconds after initialization
|
||||||
}
|
}
|
||||||
return this.initPromise
|
return this.initPromise
|
||||||
}
|
}
|
||||||
|
|
@ -440,6 +451,99 @@ class IndexedDbService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async putEvents(items: { event: Event; relays: string[] }[]): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.EVENTS, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.EVENTS)
|
||||||
|
|
||||||
|
let completed = 0
|
||||||
|
items.forEach((item) => {
|
||||||
|
const putRequest = store.put(item)
|
||||||
|
putRequest.onsuccess = () => {
|
||||||
|
completed++
|
||||||
|
if (completed === items.length) {
|
||||||
|
transaction.commit()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEvents({ limit, ...filter }: Filter): Promise<{ event: Event; relays: string[] }[]> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.EVENTS, 'readonly')
|
||||||
|
const store = transaction.objectStore(StoreNames.EVENTS)
|
||||||
|
const index = store.index('createdAtIndex')
|
||||||
|
const request = index.openCursor(null, 'prev')
|
||||||
|
|
||||||
|
const results: { event: Event; relays: string[] }[] = []
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest).result
|
||||||
|
if (cursor && (!limit || results.length < limit)) {
|
||||||
|
const item = cursor.value as { event: Event; relays: string[] }
|
||||||
|
if (matchFilter(filter, item.event)) {
|
||||||
|
results.push(item)
|
||||||
|
}
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
transaction.commit()
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEvents(filter: Filter & { until: number }): Promise<void> {
|
||||||
|
await this.initPromise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.db) {
|
||||||
|
return reject('database not initialized')
|
||||||
|
}
|
||||||
|
const transaction = this.db.transaction(StoreNames.EVENTS, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.EVENTS)
|
||||||
|
const index = store.index('createdAtIndex')
|
||||||
|
const request = index.openCursor(IDBKeyRange.upperBound(filter.until, true))
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest).result
|
||||||
|
if (cursor) {
|
||||||
|
const item = cursor.value as { event: Event; relays: string[] }
|
||||||
|
if (matchFilter(filter, item.event)) {
|
||||||
|
cursor.delete()
|
||||||
|
}
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
transaction.commit()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
reject(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private getReplaceableEventKeyFromEvent(event: Event): string {
|
private getReplaceableEventKeyFromEvent(event: Event): string {
|
||||||
if (
|
if (
|
||||||
[kinds.Metadata, kinds.Contacts].includes(event.kind) ||
|
[kinds.Metadata, kinds.Contacts].includes(event.kind) ||
|
||||||
|
|
@ -559,6 +663,33 @@ class IndexedDbService {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cleanUpOldEvents() {
|
||||||
|
await this.initPromise
|
||||||
|
if (!this.db) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = this.db!.transaction(StoreNames.EVENTS, 'readwrite')
|
||||||
|
const store = transaction.objectStore(StoreNames.EVENTS)
|
||||||
|
const index = store.index('createdAtIndex')
|
||||||
|
const request = index.openCursor(IDBKeyRange.upperBound(dayjs().subtract(5, 'days').unix()))
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest).result
|
||||||
|
if (cursor) {
|
||||||
|
cursor.delete()
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
transaction.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
|
console.error('Failed to clean up old events:', event)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = IndexedDbService.getInstance()
|
const instance = IndexedDbService.getInstance()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue