import { FormattedTimestamp } from '@/components/FormattedTimestamp' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar' import Username, { SimpleUsername } from '@/components/Username' import { isMentioningMutedUsers } from '@/lib/event' import { toNote, toUserAggregationDetail } from '@/lib/link' import { cn, isTouchDevice } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { usePinnedUsers } from '@/providers/PinnedUsersProvider' import { useReply } from '@/providers/ReplyProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service' import { TFeedSubRequest } from '@/types' import dayjs from 'dayjs' import { History, Loader, Star } from 'lucide-react' 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 { LoadingBar } from '../LoadingBar' import NewNotesButton from '../NewNotesButton' const LIMIT = 500 const SHOW_COUNT = 20 export type TUserAggregationListRef = { scrollToTop: (behavior?: ScrollBehavior) => void refresh: () => void } const UserAggregationList = forwardRef< TUserAggregationListRef, { subRequests: TFeedSubRequest[] showKinds?: number[] filterMutedNotes?: boolean areAlgoRelays?: boolean showRelayCloseReason?: boolean } >( ( { subRequests, showKinds, filterMutedNotes = true, areAlgoRelays = false, showRelayCloseReason = false }, ref ) => { const { t } = useTranslation() const { pubkey: currentPubkey, startLogin } = useNostr() const { push } = useSecondaryPage() const { hideUntrustedNotes, isUserTrusted } = useUserTrust() const { mutePubkeySet } = useMuteList() const { pinnedPubkeySet } = usePinnedUsers() const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() const { addReplies } = useReply() const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix()) const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) const [newEventPubkeys, setNewEventPubkeys] = useState>(new Set()) const [timelineKey, setTimelineKey] = useState(undefined) const [loading, setLoading] = useState(true) const [showLoadingBar, setShowLoadingBar] = useState(true) const [refreshCount, setRefreshCount] = useState(0) const [showCount, setShowCount] = useState(SHOW_COUNT) const [hasMore, setHasMore] = useState(true) const supportTouch = useMemo(() => isTouchDevice(), []) const feedId = useMemo(() => { return userAggregationService.getFeedId(subRequests, showKinds) }, [JSON.stringify(subRequests), JSON.stringify(showKinds)]) const bottomRef = useRef(null) const topRef = useRef(null) const nonPinnedTopRef = useRef(null) const scrollToTop = (behavior: ScrollBehavior = 'instant') => { setTimeout(() => { topRef.current?.scrollIntoView({ behavior, block: 'start' }) }, 20) } const refresh = () => { scrollToTop() setTimeout(() => { setRefreshCount((count) => count + 1) }, 500) } useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) useEffect(() => { return () => { userAggregationService.clearAggregations(feedId) } }, [feedId]) useEffect(() => { if (!subRequests.length) return setSince(dayjs().subtract(1, 'day').unix()) setHasMore(true) async function init() { setLoading(true) setEvents([]) setNewEvents([]) setHasMore(true) if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { setLoading(false) setHasMore(false) return () => {} } const preprocessedSubRequests = await Promise.all( subRequests.map(async ({ urls, filter }) => { const relays = urls.length ? urls : await client.determineRelaysByFilter(filter) return { urls: relays, filter: { kinds: showKinds ?? [], ...filter, limit: LIMIT } } }) ) const { closer, timelineKey } = await client.subscribeTimeline( preprocessedSubRequests, { onEvents: (events, eosed) => { if (events.length > 0) { setEvents(events) } if (areAlgoRelays) { setHasMore(false) } if (eosed) { setLoading(false) setHasMore(events.length > 0) addReplies(events) } }, onNew: (event) => { setNewEvents((oldEvents) => [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) addReplies([event]) }, onClose: (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 } ) setTimelineKey(timelineKey) return closer } const promise = init() return () => { promise.then((closer) => closer()) } }, [feedId, refreshCount]) useEffect(() => { if (loading || !hasMore || !timelineKey || !events.length) { return } const until = events[events.length - 1].created_at - 1 if (until < since) { return } setLoading(true) client.loadMoreTimeline(timelineKey, until, LIMIT).then((moreEvents) => { if (moreEvents.length === 0) { setHasMore(false) setLoading(false) return } setEvents((oldEvents) => [...oldEvents, ...moreEvents]) setLoading(false) }) }, [loading, timelineKey, events, since, hasMore]) useEffect(() => { if (loading) { setShowLoadingBar(true) return } const timeout = setTimeout(() => { setShowLoadingBar(false) }, 1000) return () => clearTimeout(timeout) }, [loading]) const shouldHideEvent = useCallback( (evt: Event) => { if (evt.pubkey === currentPubkey) return true if (isEventDeleted(evt)) return true if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true if ( filterMutedNotes && hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet) ) { return true } return false }, [ hideUntrustedNotes, mutePubkeySet, isEventDeleted, currentPubkey, filterMutedNotes, isUserTrusted, hideContentMentioningMutedUsers, isMentioningMutedUsers ] ) const lastXDays = useMemo(() => { return dayjs().diff(dayjs.unix(since), 'day') }, [since]) const filteredEvents = useMemo(() => { return events.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt)) }, [events, since, shouldHideEvent]) const filteredNewEvents = useMemo(() => { return newEvents.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt)) }, [newEvents, since, shouldHideEvent]) const aggregations = useMemo(() => { const aggs = userAggregationService.aggregateByUser(filteredEvents) userAggregationService.saveAggregations(feedId, aggs) return aggs }, [feedId, filteredEvents]) const pinnedAggregations = useMemo(() => { return aggregations.filter((agg) => pinnedPubkeySet.has(agg.pubkey)) }, [aggregations, pinnedPubkeySet]) const normalAggregations = useMemo(() => { return aggregations.filter((agg) => !pinnedPubkeySet.has(agg.pubkey)) }, [aggregations, pinnedPubkeySet]) const displayedNormalAggregations = useMemo(() => { return normalAggregations.slice(0, showCount) }, [normalAggregations, showCount]) const hasMoreToDisplay = useMemo(() => { return normalAggregations.length > displayedNormalAggregations.length }, [normalAggregations, displayedNormalAggregations]) useEffect(() => { const options = { root: null, rootMargin: '10px', threshold: 1 } if (!hasMoreToDisplay) return const observerInstance = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { setShowCount((count) => count + SHOW_COUNT) } }, options) const currentBottomRef = bottomRef.current if (currentBottomRef) { observerInstance.observe(currentBottomRef) } return () => { if (observerInstance && currentBottomRef) { observerInstance.unobserve(currentBottomRef) } } }, [hasMoreToDisplay]) const handleViewUser = (agg: TUserAggregation) => { // Mark as viewed when user clicks userAggregationService.markAsViewed(feedId, agg.pubkey) setNewEventPubkeys((prev) => { const newSet = new Set(prev) newSet.delete(agg.pubkey) return newSet }) if (agg.count === 1) { const evt = agg.events[0] if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) { push(toNote(agg.events[0])) return } } push(toUserAggregationDetail(feedId, agg.pubkey)) } const handleLoadEarlier = () => { setSince((prevSince) => dayjs.unix(prevSince).subtract(1, 'day').unix()) setShowCount(SHOW_COUNT) } const showNewEvents = () => { const pubkeySet = new Set() let hasPinnedUser = false newEvents.forEach((evt) => { pubkeySet.add(evt.pubkey) if (pinnedPubkeySet.has(evt.pubkey)) { hasPinnedUser = true } }) setNewEventPubkeys(pubkeySet) setEvents((oldEvents) => [...newEvents, ...oldEvents]) setNewEvents([]) setTimeout(() => { if (hasPinnedUser) { scrollToTop('smooth') return } nonPinnedTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 0) } const list = (
{pinnedAggregations.map((agg) => ( handleViewUser(agg)} isNew={newEventPubkeys.has(agg.pubkey)} /> ))}
{normalAggregations.map((agg) => ( handleViewUser(agg)} isNew={newEventPubkeys.has(agg.pubkey)} /> ))} {loading || hasMoreToDisplay ? (
) : aggregations.length === 0 ? (
) : (
{t('no more notes')}
)}
) return (
{showLoadingBar && }
{lastXDays === 1 ? t('Last 24 hours') : t('Last {{count}} days', { count: lastXDays })} ยท {filteredEvents.length} {t('notes')}
{supportTouch ? ( { refresh() await new Promise((resolve) => setTimeout(resolve, 1000)) }} pullingContent="" > {list} ) : ( list )}
{filteredNewEvents.length > 0 && ( )}
) } ) UserAggregationList.displayName = 'UserAggregationList' export default UserAggregationList function UserAggregationItem({ feedId, aggregation, onClick, isNew }: { feedId: string aggregation: TUserAggregation onClick: () => void isNew?: boolean }) { const { t } = useTranslation() const supportTouch = useMemo(() => isTouchDevice(), []) const [hasNewEvents, setHasNewEvents] = useState(true) const [loading, setLoading] = useState(false) const { isPinned, togglePin } = usePinnedUsers() const pinned = useMemo(() => isPinned(aggregation.pubkey), [aggregation.pubkey, isPinned]) useEffect(() => { const update = () => { const lastViewedTime = userAggregationService.getLastViewedTime(feedId, aggregation.pubkey) setHasNewEvents(aggregation.lastEventTime > lastViewedTime) } const unSub = userAggregationService.subscribeViewedTimeChange( feedId, aggregation.pubkey, () => { update() } ) update() return unSub }, [feedId, aggregation]) const onTogglePin = (e: React.MouseEvent) => { e.stopPropagation() setLoading(true) togglePin(aggregation.pubkey).finally(() => { setLoading(false) }) } const onToggleViewed = (e: React.MouseEvent) => { e.stopPropagation() if (hasNewEvents) { userAggregationService.markAsViewed(feedId, aggregation.pubkey) } else { userAggregationService.markAsUnviewed(feedId, aggregation.pubkey) } } return (
{supportTouch ? ( ) : ( )}
{supportTouch ? ( ) : ( )}
) } function UserAggregationItemSkeleton() { return (
) }