You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

484 lines
16 KiB

import NewNotesButton from '@/components/NewNotesButton'
import { Button } from '@/components/ui/button'
import {
getReplaceableCoordinateFromEvent,
isMentioningMutedUsers,
isReplaceableEvent,
isReplyNoteEvent
} from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import { toast } from 'sonner'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const LIMIT = 100
const ALGO_LIMIT = 100
const SHOW_COUNT = 10
const NoteList = forwardRef(
(
{
subRequests,
showKinds,
filterMutedNotes = true,
hideReplies = false,
hideUntrustedNotes = false,
areAlgoRelays = false,
showRelayCloseReason = false,
customHeader
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
filterMutedNotes?: boolean
hideReplies?: boolean
hideUntrustedNotes?: boolean
areAlgoRelays?: boolean
showRelayCloseReason?: boolean
customHeader?: React.ReactNode
},
ref
) => {
const { t } = useTranslation()
const { startLogin, pubkey } = useNostr()
const { isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap()
const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [refreshCount, setRefreshCount] = useState(0)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const supportTouch = useMemo(() => isTouchDevice(), [])
const bottomRef = useRef<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(null)
const shouldHideEvent = useCallback(
(evt: Event) => {
// Check if this is a profile feed
const isProfileFeed = subRequests.some(req => req.filter.authors && req.filter.authors.length === 1)
console.log('🔍 [NoteList] Checking shouldHideEvent for:', {
id: evt.id,
kind: evt.kind,
pubkey: evt.pubkey.substring(0, 8),
isDeleted: isEventDeleted(evt),
isReply: isReplyNoteEvent(evt),
isTrusted: isUserTrusted(evt.pubkey),
isMuted: mutePubkeySet.has(evt.pubkey),
hideReplies,
hideUntrustedNotes,
filterMutedNotes,
isProfileFeed
})
if (isEventDeleted(evt)) {
console.log('❌ [NoteList] Event deleted:', evt.id)
return true
}
// Special handling for zaps - check threshold, but be more lenient for profile feeds
if (evt.kind === kinds.Zap) {
const zapInfo = getZapInfoFromEvent(evt)
// For profile feeds, show all zaps from the profile owner
// For timeline feeds, filter by threshold
if (!isProfileFeed && zapInfo && zapInfo.amount < zapReplyThreshold) {
console.log('❌ [NoteList] Zap below threshold:', { id: evt.id, amount: zapInfo.amount, threshold: zapReplyThreshold })
return true
}
} else if (hideReplies && isReplyNoteEvent(evt)) {
console.log('❌ [NoteList] Reply hidden:', evt.id)
return true
}
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) {
console.log('❌ [NoteList] Untrusted user:', evt.id)
return true
}
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) {
console.log('❌ [NoteList] Muted user:', evt.id)
return true
}
if (
filterMutedNotes &&
hideContentMentioningMutedUsers &&
isMentioningMutedUsers(evt, mutePubkeySet)
) {
console.log('❌ [NoteList] Mentions muted users:', evt.id)
return true
}
console.log('✅ [NoteList] Event passed all filters:', evt.id)
return false
},
[hideReplies, hideUntrustedNotes, mutePubkeySet, isEventDeleted, zapReplyThreshold, subRequests]
)
const filteredEvents = useMemo(() => {
const idSet = new Set<string>()
console.log('🔍 [NoteList] Filtering events:', {
totalEvents: events.length,
showCount,
eventKinds: events.map(e => e.kind).slice(0, 10)
})
const filtered = events.slice(0, showCount).filter((evt) => {
if (shouldHideEvent(evt)) {
console.log('❌ [NoteList] Event hidden:', { id: evt.id, kind: evt.kind, reason: 'shouldHideEvent' })
return false
}
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
if (idSet.has(id)) {
console.log('❌ [NoteList] Event hidden:', { id: evt.id, kind: evt.kind, reason: 'duplicate' })
return false
}
idSet.add(id)
return true
})
console.log('✅ [NoteList] Filtered events result:', {
total: events.length,
filtered: filtered.length,
showCount
})
return filtered
}, [events, showCount, shouldHideEvent])
const filteredNewEvents = useMemo(() => {
const idSet = new Set<string>()
return newEvents.filter((event: Event) => {
if (shouldHideEvent(event)) return false
const id = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: event.id
if (idSet.has(id)) {
return false
}
idSet.add(id)
return true
})
}, [newEvents, shouldHideEvent])
const scrollToTop = (behavior: ScrollBehavior = 'instant') => {
setTimeout(() => {
topRef.current?.scrollIntoView({ behavior, block: 'start' })
}, 20)
}
const refresh = () => {
scrollToTop()
// Clear relay connection state to force fresh connections
const relayUrls = subRequests.flatMap(req => req.urls)
client.clearRelayConnectionState(relayUrls)
setTimeout(() => {
setRefreshCount((count) => count + 1)
}, 500)
}
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [])
useEffect(() => {
console.log('🚀 [NoteList] useEffect triggered:', {
subRequests: subRequests.length,
showKinds: showKinds.length,
refreshCount
})
logger.debug('NoteList useEffect:', { subRequests, subRequestsLength: subRequests.length })
if (!subRequests.length) return
async function init() {
console.log('🔄 [NoteList] Initializing feed...')
setLoading(true)
setEvents([])
setNewEvents([])
setHasMore(true)
if (showKinds.length === 0) {
logger.warn('NoteList: showKinds is empty, no events will be displayed')
setLoading(false)
setHasMore(false)
return () => {}
}
const finalFilters = subRequests.map(({ urls, filter }) => ({
urls,
filter: {
kinds: showKinds,
...filter,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}))
console.log('[NoteList] Subscribing to timeline with:', finalFilters)
console.log('[NoteList] showKinds:', showKinds)
const { closer, timelineKey } = await client.subscribeTimeline(
finalFilters,
{
onEvents: (events, eosed) => {
console.log('📥 [NoteList] Received events:', {
eventsCount: events.length,
eosed,
loading,
hasMore,
eventKinds: events.map(e => e.kind).slice(0, 10), // First 10 event kinds
showKinds
})
logger.debug('NoteList received events:', { eventsCount: events.length, eosed })
if (events.length > 0) {
console.log('✅ [NoteList] Accumulating events from relay')
setEvents(prevEvents => {
// For profile feeds, accumulate events from all relays
// For timeline feeds, replace events
const isProfileFeed = subRequests.some(req => req.filter.authors && req.filter.authors.length === 1)
if (isProfileFeed) {
// Accumulate events, removing duplicates
const existingIds = new Set(prevEvents.map(e => e.id))
const newEvents = events.filter(e => !existingIds.has(e.id))
const combined = [...prevEvents, ...newEvents]
console.log('📊 [NoteList] Profile feed - accumulated:', {
previous: prevEvents.length,
new: events.length,
unique: newEvents.length,
total: combined.length
})
return combined
} else {
// Timeline feed - replace events
return events
}
})
// Stop loading as soon as we have events, don't wait for all relays
setLoading(false)
}
if (areAlgoRelays) {
setHasMore(false)
}
if (eosed) {
console.log('🏁 [NoteList] EOSED - setting loading false, hasMore:', events.length > 0)
setLoading(false)
setHasMore(events.length > 0)
}
},
onNew: (event) => {
if (pubkey && event.pubkey === pubkey) {
// If the new event is from the current user, insert it directly into the feed
setEvents((oldEvents) =>
oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents]
)
} else {
// Otherwise, buffer it and show the New Notes button
setNewEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
}
},
onClose: (url, reason) => {
logger.debug('Relay connection closed:', { url, reason })
if (!showRelayCloseReason) return
// ignore reasons from nostr-tools
if (
[
'closed by caller',
'relay connection errored',
'relay connection closed',
'pingpong timed out',
'relay connection closed by us'
].includes(reason)
) {
return
}
toast.error(`${url}: ${reason}`)
}
},
{
startLogin,
needSort: !areAlgoRelays
}
)
// Add a fallback timeout to prevent infinite loading
// Increased timeout to 15 seconds to handle slow relay connections
const fallbackTimeout = setTimeout(() => {
if (loading) {
setLoading(false)
logger.debug('NoteList loading timeout - stopping after 15 seconds')
}
}, 15000)
setTimelineKey(timelineKey)
return () => {
clearTimeout(fallbackTimeout)
closer?.()
}
}
const promise = init()
return () => {
promise.then((closer) => closer())
}
}, [subRequests, refreshCount, showKinds])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = async () => {
if (showCount < events.length) {
setShowCount((prev) => prev + SHOW_COUNT)
// preload more
if (events.length - showCount > LIMIT / 2) {
return
}
}
if (!timelineKey || loading || !hasMore) return
setLoading(true)
const newEvents = await client.loadMoreTimeline(
timelineKey,
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
LIMIT
)
setLoading(false)
if (newEvents.length === 0) {
setHasMore(false)
return
}
setEvents((oldEvents) => [...oldEvents, ...newEvents])
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [loading, hasMore, events, showCount, timelineKey])
const showNewEvents = () => {
setEvents((oldEvents) => [...newEvents, ...oldEvents])
setNewEvents([])
setTimeout(() => {
scrollToTop('smooth')
}, 0)
}
console.log('🎨 [NoteList] Rendering with state:', {
eventsCount: events.length,
filteredEventsCount: filteredEvents.length,
loading,
hasMore,
showKinds: showKinds.length
})
const list = (
<div className="min-h-screen">
{customHeader}
{filteredEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={filterMutedNotes}
/>
))}
{hasMore || loading ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton />
</div>
) : events.length ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
) : (
<div className="flex justify-center w-full mt-2">
<Button size="lg" onClick={() => {
console.log('🔄 [NoteList] Reload button clicked, refreshing feed')
// Clear relay connection state to force fresh connections
const relayUrls = subRequests.flatMap(req => req.urls)
client.clearRelayConnectionState(relayUrls)
setRefreshCount((count) => count + 1)
}}>
{t('reload notes')}
</Button>
</div>
)}
</div>
)
return (
<div>
{filteredNewEvents.length > 0 && (
<NewNotesButton newEvents={filteredNewEvents} onClick={showNewEvents} />
)}
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
{supportTouch ? (
<PullToRefresh
onRefresh={async () => {
refresh()
await new Promise((resolve) => setTimeout(resolve, 1000))
}}
pullingContent=""
>
{list}
</PullToRefresh>
) : (
list
)}
<div className="h-40" />
</div>
)
}
)
NoteList.displayName = 'NoteList'
export default NoteList
export type TNoteListRef = {
scrollToTop: (behavior?: ScrollBehavior) => void
refresh: () => void
}