import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' import { isReplyNoteEvent } from '@/lib/event' import { checkAlgoRelay } from '@/lib/relay' import { cn } from '@/lib/utils' import NewNotesButton from '@/components/NewNotesButton' import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import client from '@/services/client.service' import storage from '@/services/local-storage.service' import relayInfoService from '@/services/relay-info.service' import { TNoteListMode } from '@/types' import dayjs from 'dayjs' import { Event, Filter, kinds } from 'nostr-tools' import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import NoteCard from '../NoteCard' import PictureNoteCard from '../PictureNoteCard' const LIMIT = 100 const ALGO_LIMIT = 500 const SHOW_COUNT = 10 export default function NoteList({ relayUrls = [], filter = {}, className, filterMutedNotes = true, needCheckAlgoRelay = false }: { relayUrls?: string[] filter?: Filter className?: string filterMutedNotes?: boolean needCheckAlgoRelay?: boolean }) { const { t } = useTranslation() const { isLargeScreen } = useScreenSize() const { startLogin } = useNostr() const { mutePubkeys } = useMuteList() const [refreshCount, setRefreshCount] = useState(0) const [timelineKey, setTimelineKey] = useState(undefined) const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) const [showCount, setShowCount] = useState(SHOW_COUNT) const [hasMore, setHasMore] = useState(true) const [loading, setLoading] = useState(true) const [listMode, setListMode] = useState(() => storage.getNoteListMode()) const bottomRef = useRef(null) const isPictures = useMemo(() => listMode === 'pictures', [listMode]) const noteFilter = useMemo(() => { return { kinds: isPictures ? [ExtendedKind.PICTURE] : [kinds.ShortTextNote, kinds.Repost], ...filter } }, [JSON.stringify(filter), isPictures]) const topRef = useRef(null) const filteredNewEvents = useMemo(() => { return newEvents.filter((event: Event) => { return ( (!filterMutedNotes || !mutePubkeys.includes(event.pubkey)) && (listMode !== 'posts' || !isReplyNoteEvent(event)) ) }) }, [newEvents, listMode, filterMutedNotes, mutePubkeys]) useEffect(() => { if (relayUrls.length === 0 && !noteFilter.authors?.length) return async function init() { setLoading(true) setEvents([]) setNewEvents([]) setHasMore(true) let areAlgoRelays = false if (needCheckAlgoRelay) { const relayInfos = await relayInfoService.getRelayInfos(relayUrls) areAlgoRelays = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)) } const { closer, timelineKey } = await client.subscribeTimeline( [...relayUrls], { ...noteFilter, limit: areAlgoRelays ? ALGO_LIMIT : LIMIT }, { onEvents: (events, eosed) => { if (events.length > 0) { setEvents(events) } if (areAlgoRelays) { setHasMore(false) } if (eosed) { setLoading(false) setHasMore(events.length > 0) } }, onNew: (event) => { setNewEvents((oldEvents) => [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) } }, { startLogin, needSort: !areAlgoRelays } ) setTimelineKey(timelineKey) return closer } const promise = init() return () => { promise.then((closer) => closer()) } }, [JSON.stringify(relayUrls), noteFilter, refreshCount]) 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) } } }, [timelineKey, loading, hasMore, events, noteFilter, showCount]) const showNewEvents = () => { topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) setEvents((oldEvents) => [...newEvents, ...oldEvents]) setNewEvents([]) } return (
{ setListMode(listMode) setShowCount(SHOW_COUNT) topRef.current?.scrollIntoView({ behavior: 'instant', block: 'end' }) storage.setNoteListMode(listMode) }} />
{filteredNewEvents.length > 0 && ( )} { setRefreshCount((count) => count + 1) await new Promise((resolve) => setTimeout(resolve, 1000)) }} pullingContent="" >
{isPictures ? ( ) : (
{events .slice(0, showCount) .filter((event: Event) => listMode !== 'posts' || !isReplyNoteEvent(event)) .map((event) => ( ))}
)} {hasMore || loading ? (
) : events.length ? (
{t('no more notes')}
) : (
)}
) } function ListModeSwitch({ listMode, setListMode }: { listMode: TNoteListMode setListMode: (listMode: TNoteListMode) => void }) { const { t } = useTranslation() const { deepBrowsing, lastScrollTop } = useDeepBrowsing() return (
800 ? '-translate-y-[calc(100%+12rem)]' : '' )} >
setListMode('posts')} > {t('Notes')}
setListMode('postsAndReplies')} > {t('Replies')}
setListMode('pictures')} > {t('Pictures')}
) } function PictureNoteCardMasonry({ events, columnCount, className }: { events: Event[] columnCount: 2 | 3 className?: string }) { const columns = useMemo(() => { const newColumns: ReactNode[][] = Array.from({ length: columnCount }, () => []) events.forEach((event, i) => { newColumns[i % columnCount].push( ) }) return newColumns }, [events, columnCount]) return (
{columns.map((column, i) => (
{column}
))}
) } function LoadingSkeleton({ isPictures }: { isPictures: boolean }) { const { t } = useTranslation() if (isPictures) { return
{t('loading...')}
} return (
) }