index.tsx raw

   1  import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
   2  import { useStuff } from '@/hooks/useStuff'
   3  import { useAllDescendantThreads } from '@/hooks/useThread'
   4  import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
   5  import { useContentPolicy } from '@/providers/ContentPolicyProvider'
   6  import { useMuteList } from '@/providers/MuteListProvider'
   7  import { useUserTrust } from '@/providers/UserTrustProvider'
   8  import threadService from '@/services/thread.service'
   9  import { Event as NEvent } from 'nostr-tools'
  10  import { useCallback, useEffect, useMemo, useState } from 'react'
  11  import { useTranslation } from 'react-i18next'
  12  import { LoadingBar } from '../LoadingBar'
  13  import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
  14  import SubReplies from './SubReplies'
  15  
  16  const LIMIT = 100
  17  const SHOW_COUNT = 10
  18  
  19  export default function ReplyNoteList({ stuff, navIndexOffset = 0 }: { stuff: NEvent | string; navIndexOffset?: number }) {
  20    const { t } = useTranslation()
  21    const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
  22    const { mutePubkeySet } = useMuteList()
  23    const { hideContentMentioningMutedUsers } = useContentPolicy()
  24    const { stuffKey } = useStuff(stuff)
  25    const allThreads = useAllDescendantThreads(stuffKey)
  26    const [initialLoading, setInitialLoading] = useState(false)
  27  
  28    const replies = useMemo(() => {
  29      const replyKeySet = new Set<string>()
  30      const thread = allThreads.get(stuffKey) || []
  31      const replyEvents = thread.filter((evt) => {
  32        const key = getEventKey(evt)
  33        if (replyKeySet.has(key)) return false
  34        if (mutePubkeySet.has(evt.pubkey)) return false
  35        if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) {
  36          return false
  37        }
  38        if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
  39          const replyKey = getEventKey(evt)
  40          const repliesForThisReply = allThreads.get(replyKey)
  41          // If the reply is not trusted and there are no trusted replies for this reply, skip rendering
  42          if (
  43            !repliesForThisReply ||
  44            repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
  45          ) {
  46            return false
  47          }
  48        }
  49  
  50        replyKeySet.add(key)
  51        return true
  52      })
  53      return replyEvents.sort((a, b) => b.created_at - a.created_at)
  54    }, [
  55      stuffKey,
  56      allThreads,
  57      mutePubkeySet,
  58      hideContentMentioningMutedUsers,
  59      hideUntrustedInteractions,
  60      isUserTrusted
  61    ])
  62  
  63    // Initial subscription
  64    useEffect(() => {
  65      const loadInitial = async () => {
  66        setInitialLoading(true)
  67        await threadService.subscribe(stuff, LIMIT)
  68        setInitialLoading(false)
  69      }
  70  
  71      loadInitial()
  72  
  73      return () => {
  74        threadService.unsubscribe(stuff)
  75      }
  76    }, [stuff])
  77  
  78    const handleLoadMore = useCallback(async () => {
  79      return await threadService.loadMore(stuff, LIMIT)
  80    }, [stuff])
  81  
  82    const { visibleItems, loading, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
  83      items: replies,
  84      showCount: SHOW_COUNT,
  85      onLoadMore: handleLoadMore,
  86      initialLoading
  87    })
  88  
  89    return (
  90      <div className="min-h-[80vh]">
  91        {(loading || initialLoading) && <LoadingBar />}
  92        <div>
  93          {visibleItems.map((reply, index) => (
  94            <Item key={reply.id} reply={reply} navIndex={navIndexOffset + index} />
  95          ))}
  96        </div>
  97        <div ref={bottomRef} />
  98        {shouldShowLoadingIndicator ? (
  99          <ReplyNoteSkeleton />
 100        ) : (
 101          <div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
 102            {replies.length > 0 ? t('no more replies') : t('no replies')}
 103          </div>
 104        )}
 105      </div>
 106    )
 107  }
 108  
 109  // Use larger gaps between items to leave room for sub-replies
 110  const NAV_INDEX_MULTIPLIER = 100
 111  
 112  function Item({ reply, navIndex }: { reply: NEvent; navIndex: number }) {
 113    const key = useMemo(() => getEventKey(reply), [reply])
 114    const baseNavIndex = navIndex * NAV_INDEX_MULTIPLIER
 115  
 116    return (
 117      <div className="relative border-b">
 118        <ReplyNote event={reply} navColumn={2} navIndex={baseNavIndex} />
 119        <SubReplies parentKey={key} revealerNavIndex={baseNavIndex + 1} subReplyNavIndexStart={baseNavIndex + 2} />
 120      </div>
 121    )
 122  }
 123