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.
 
 
 
 

281 lines
9.6 KiB

import NoteCard from '@/components/NoteCard'
import ProfileSearchBar from '@/components/ui/ProfileSearchBar'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants'
import { isReplyNoteEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { useProfilePins } from '@/hooks/useProfilePins'
import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { RefreshCw } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const INITIAL_SHOW_COUNT = 25
const LOAD_MORE_COUNT = 25
function useHideRepliesLikeMainFeed() {
const [hideReplies, setHideReplies] = useState(() => {
const m = storage.getNoteListMode()
return m !== 'postsAndReplies'
})
useEffect(() => {
const sync = () => {
const m = storage.getNoteListMode()
setHideReplies(m !== 'postsAndReplies')
}
window.addEventListener('noteListModeChanged', sync)
return () => window.removeEventListener('noteListModeChanged', sync)
}, [])
return hideReplies
}
const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter()
/** Profile timelines always show reposts; global kind filter still applies to other kinds. */
const profileTimelineShowKinds = useMemo(() => {
if (showKinds.includes(kinds.Repost)) return showKinds
return [...showKinds, kinds.Repost].sort((a, b) => a - b)
}, [showKinds])
const hideReplies = useHideRepliesLikeMainFeed()
const [searchQuery, setSearchQuery] = useState('')
const [isRefreshing, setIsRefreshing] = useState(false)
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey)
const filterPredicate = useCallback(
(event: Event) => {
if (event.kind === ExtendedKind.ZAP_RECEIPT) {
const zapInfo = getZapInfoFromEvent(event)
if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) {
return false
}
}
return true
},
[zapReplyThreshold]
)
const cacheKey = useMemo(() => `${pubkey}-profile-unified-${zapReplyThreshold}`, [pubkey, zapReplyThreshold])
const { events: timelineEvents, isLoading: loadingTimeline, refresh: refreshTimeline } = useProfileTimeline({
pubkey,
cacheKey,
kinds: PROFILE_FEED_KINDS,
limit: 200,
filterPredicate
})
const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents])
const passesMainFeedTimelineRules = useCallback(
(event: Event) => {
if (!profileTimelineShowKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (hideReplies && isReply) return false
if (isReply && !showKind1Replies) return false
if (!isReply && !showKind1OPs) return false
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false
return true
},
[profileTimelineShowKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies]
)
const restTimeline = useMemo(
() => timelineEvents.filter((e) => !pinIds.has(e.id)).filter(passesMainFeedTimelineRules),
[timelineEvents, pinIds, passesMainFeedTimelineRules]
)
const applySearch = useCallback(
(events: Event[]) => {
const q = searchQuery.trim().toLowerCase()
if (!q) return events
return events.filter((event) => {
if (event.content.toLowerCase().includes(q)) return true
return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q))
})
},
[searchQuery]
)
const filteredPins = useMemo(
() => applySearch(pinEvents).filter((e) => !isEventDeleted(e)),
[pinEvents, applySearch, isEventDeleted]
)
const filteredRest = useMemo(
() => applySearch(restTimeline).filter((e) => !isEventDeleted(e)),
[restTimeline, applySearch, isEventDeleted]
)
const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest])
/** Pins always occupy the top of the profile; `showCount` caps total visible rows (pins + posts). */
const displayedPins = useMemo(() => {
if (filteredPins.length <= showCount) return filteredPins
return filteredPins.slice(0, showCount)
}, [filteredPins, showCount])
const displayedFeed = useMemo(
() => filteredRest.slice(0, Math.max(0, showCount - displayedPins.length)),
[filteredRest, showCount, displayedPins.length]
)
const totalVisible = displayedPins.length + displayedFeed.length
useEffect(() => {
setShowCount(INITIAL_SHOW_COUNT)
}, [searchQuery, pubkey])
useEffect(() => {
if (!loadingPins && !loadingTimeline) {
setIsRefreshing(false)
}
}, [loadingPins, loadingTimeline])
const refreshAll = useCallback(() => {
setIsRefreshing(true)
refreshPins()
refreshTimeline()
void client.fetchDeletionEventsForPubkey(pubkey)
}, [refreshPins, refreshTimeline, pubkey])
useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll])
useEffect(() => {
if (!bottomRef.current || totalVisible >= mergedDisplay.length) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && totalVisible < mergedDisplay.length) {
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, mergedDisplay.length))
}
},
{ threshold: 0.1 }
)
observer.observe(bottomRef.current)
return () => observer.disconnect()
}, [totalVisible, mergedDisplay.length])
const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0
if (loading) {
return (
<div className="mt-4 space-y-2 px-1">
<div className="flex flex-wrap items-center gap-2 px-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search posts...')}
className="w-64 max-w-full"
/>
</div>
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
</div>
)
}
if (!mergedDisplay.length && !loadingPins && !loadingTimeline) {
return (
<div className="mt-4 px-2">
<div className="flex flex-wrap items-center gap-2 mb-4">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search posts...')}
className="w-64 max-w-full"
/>
</div>
<div className="flex justify-center py-8 text-sm text-muted-foreground">
{searchQuery.trim() ? t('No posts match your search') : t('No posts found')}
</div>
</div>
)
}
return (
<div className="mt-4">
<div className="flex flex-wrap items-center gap-2 px-2 mb-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search posts...')}
className="w-64 max-w-full"
/>
</div>
{isRefreshing && (
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{t('Refreshing posts...')}
</div>
)}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{t('Showing {{filtered}} of {{total}} items', {
filtered: totalVisible,
total: mergedDisplay.length
})}
</div>
)}
<div className="space-y-2">
{displayedPins.length > 0 && (
<div className="space-y-2" aria-label={t('Pinned posts')}>
{displayedPins.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
pinned
/>
))}
</div>
)}
{displayedPins.length > 0 && displayedFeed.length > 0 && (
<div className="text-xs text-muted-foreground px-2 py-1 border-t border-border/60 mt-2 pt-2">
{t('Feed')}
</div>
)}
{displayedFeed.length > 0 && (
<div className="space-y-2" aria-label={t('Posts')}>
{displayedFeed.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
pinned={false}
/>
))}
</div>
)}
</div>
{totalVisible < mergedDisplay.length && (
<div ref={bottomRef} className="flex h-10 items-center justify-center">
<div className="text-sm text-muted-foreground">{t('Loading more...')}</div>
</div>
)}
</div>
)
})
ProfileFeedWithPins.displayName = 'ProfileFeedWithPins'
export default ProfileFeedWithPins