SubReplies.tsx raw

   1  import { useSecondaryPage } from '@/PageManager'
   2  import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
   3  import { useAllDescendantThreads } from '@/hooks/useThread'
   4  import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
   5  import { toNote } from '@/lib/link'
   6  import { generateBech32IdFromETag } from '@/lib/tag'
   7  import { cn } from '@/lib/utils'
   8  import { useContentPolicy } from '@/providers/ContentPolicyProvider'
   9  import { useMuteList } from '@/providers/MuteListProvider'
  10  import { useUserTrust } from '@/providers/UserTrustProvider'
  11  import { ChevronDown, ChevronUp } from 'lucide-react'
  12  import { NostrEvent } from 'nostr-tools'
  13  import { useCallback, useMemo, useRef, useState } from 'react'
  14  import { useTranslation } from 'react-i18next'
  15  import ReplyNote from '../ReplyNote'
  16  
  17  export default function SubReplies({
  18    parentKey,
  19    revealerNavIndex,
  20    subReplyNavIndexStart
  21  }: {
  22    parentKey: string
  23    revealerNavIndex?: number
  24    subReplyNavIndexStart?: number
  25  }) {
  26    const { push } = useSecondaryPage()
  27    const allThreads = useAllDescendantThreads(parentKey)
  28    const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
  29    const { mutePubkeySet } = useMuteList()
  30    const { hideContentMentioningMutedUsers } = useContentPolicy()
  31    const [isExpanded, setIsExpanded] = useState(false)
  32    const replies = useMemo(() => {
  33      const replyKeySet = new Set<string>()
  34      const replyEvents: NostrEvent[] = []
  35  
  36      let parentKeys = [parentKey]
  37      while (parentKeys.length > 0) {
  38        const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
  39        events.forEach((evt) => {
  40          const key = getEventKey(evt)
  41          if (replyKeySet.has(key)) return
  42          if (mutePubkeySet.has(evt.pubkey)) return
  43          if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
  44          if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
  45            const replyKey = getEventKey(evt)
  46            const repliesForThisReply = allThreads.get(replyKey)
  47            // If the reply is not trusted and there are no trusted replies for this reply, skip rendering
  48            if (
  49              !repliesForThisReply ||
  50              repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
  51            ) {
  52              return
  53            }
  54          }
  55  
  56          replyKeySet.add(key)
  57          replyEvents.push(evt)
  58        })
  59        parentKeys = events.map((evt) => getEventKey(evt))
  60      }
  61      return replyEvents.sort((a, b) => a.created_at - b.created_at)
  62    }, [
  63      parentKey,
  64      allThreads,
  65      mutePubkeySet,
  66      hideContentMentioningMutedUsers,
  67      hideUntrustedInteractions
  68    ])
  69    const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
  70    const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
  71  
  72    const highlightReply = useCallback((key: string, eventId?: string, scrollTo = true) => {
  73      let found = false
  74      if (scrollTo) {
  75        const ref = replyRefs.current[key]
  76        if (ref) {
  77          found = true
  78          ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
  79        }
  80      }
  81      if (!found) {
  82        if (eventId) push(toNote(eventId))
  83        return
  84      }
  85  
  86      setHighlightReplyKey(key)
  87      setTimeout(() => {
  88        setHighlightReplyKey((pre) => (pre === key ? undefined : pre))
  89      }, 1500)
  90    }, [])
  91  
  92    if (replies.length === 0) return null
  93  
  94    return (
  95      <div>
  96        {replies.length > 1 && (
  97          <Revealer
  98            isExpanded={isExpanded}
  99            onToggle={() => setIsExpanded((prev) => !prev)}
 100            replyCount={replies.length}
 101            navIndex={revealerNavIndex}
 102          />
 103        )}
 104        {(isExpanded || replies.length === 1) && (
 105          <div>
 106            {replies.map((reply, index) => {
 107              const currentReplyKey = getEventKey(reply)
 108              const _parentTag = getParentTag(reply)
 109              if (_parentTag?.type !== 'e') return null
 110              const _parentKey = _parentTag ? getKeyFromTag(_parentTag.tag) : undefined
 111              const _parentEventId = generateBech32IdFromETag(_parentTag.tag)
 112              return (
 113                <div
 114                  ref={(el) => (replyRefs.current[currentReplyKey] = el)}
 115                  key={currentReplyKey}
 116                  className="scroll-mt-12 flex relative"
 117                >
 118                  <div className="absolute left-[34px] top-0 h-8 w-4 rounded-bl-lg border-l border-b z-20" />
 119                  {index < replies.length - 1 && (
 120                    <div className="absolute left-[34px] top-0 bottom-0 border-l z-20" />
 121                  )}
 122                  <ReplyNote
 123                    className="flex-1 w-0 pl-10"
 124                    event={reply}
 125                    navColumn={2}
 126                    navIndex={subReplyNavIndexStart !== undefined ? subReplyNavIndexStart + index : undefined}
 127                    parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
 128                    onClickParent={() => {
 129                      if (!_parentKey) return
 130                      highlightReply(_parentKey, _parentEventId)
 131                    }}
 132                    highlight={highlightReplyKey === currentReplyKey}
 133                  />
 134                </div>
 135              )
 136            })}
 137          </div>
 138        )}
 139      </div>
 140    )
 141  }
 142  
 143  function Revealer({
 144    isExpanded,
 145    onToggle,
 146    replyCount,
 147    navIndex
 148  }: {
 149    isExpanded: boolean
 150    onToggle: () => void
 151    replyCount: number
 152    navIndex?: number
 153  }) {
 154    const { t } = useTranslation()
 155  
 156    const { ref: revealerRef, isSelected } = useKeyboardNavigable(2, navIndex ?? 0, {
 157      meta: { type: 'note', onActivate: onToggle }
 158    })
 159  
 160    return (
 161      <div ref={revealerRef} className="scroll-mt-[6.5rem]">
 162        <button
 163          onClick={(e) => {
 164            e.stopPropagation()
 165            onToggle()
 166          }}
 167          className={cn(
 168            'relative w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable',
 169            isSelected && 'ring-2 ring-primary ring-inset'
 170          )}
 171        >
 172          <div
 173            className={cn('absolute left-[34px] top-0 bottom-0 w-px text-border z-20')}
 174            style={{
 175              background: isExpanded
 176                ? 'currentColor'
 177                : 'repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 7px)'
 178            }}
 179          />
 180          {isExpanded ? (
 181            <>
 182              <ChevronUp className="size-3.5" />
 183              <span>
 184                {t('Hide replies')} ({replyCount})
 185              </span>
 186            </>
 187          ) : (
 188            <>
 189              <ChevronDown className="size-3.5" />
 190              <span>
 191                {t('Show replies')} ({replyCount})
 192              </span>
 193            </>
 194          )}
 195        </button>
 196      </div>
 197    )
 198  }
 199