index.tsx raw

   1  import { useSecondaryPage } from '@/PageManager'
   2  import { Button } from '@/components/ui/button'
   3  import { Skeleton } from '@/components/ui/skeleton'
   4  import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
   5  import { useThread } from '@/hooks/useThread'
   6  import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
   7  import { toNote } from '@/lib/link'
   8  import { cn } from '@/lib/utils'
   9  import { useContentPolicy } from '@/providers/ContentPolicyProvider'
  10  import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
  11  import { useMuteList } from '@/providers/MuteListProvider'
  12  import { useScreenSize } from '@/providers/ScreenSizeProvider'
  13  import { useUserTrust } from '@/providers/UserTrustProvider'
  14  import { Event } from 'nostr-tools'
  15  import { useMemo, useState } from 'react'
  16  import { useTranslation } from 'react-i18next'
  17  import ClientTag from '../ClientTag'
  18  import Collapsible from '../Collapsible'
  19  import Content from '../Content'
  20  import { FormattedTimestamp } from '../FormattedTimestamp'
  21  import Nip05 from '../Nip05'
  22  import NoteOptions from '../NoteOptions'
  23  import ParentNotePreview from '../ParentNotePreview'
  24  import StuffStats from '../StuffStats'
  25  import TrustScoreBadge from '../TrustScoreBadge'
  26  import UserAvatar from '../UserAvatar'
  27  import Username from '../Username'
  28  
  29  export default function ReplyNote({
  30    event,
  31    parentEventId,
  32    onClickParent = () => {},
  33    highlight = false,
  34    className = '',
  35    navColumn,
  36    navIndex
  37  }: {
  38    event: Event
  39    parentEventId?: string
  40    onClickParent?: () => void
  41    highlight?: boolean
  42    className?: string
  43    navColumn?: TNavigationColumn
  44    navIndex?: number
  45  }) {
  46    const { t } = useTranslation()
  47    const { isSmallScreen } = useScreenSize()
  48    const { push } = useSecondaryPage()
  49    const { mutePubkeySet } = useMuteList()
  50    const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
  51    const { hideContentMentioningMutedUsers } = useContentPolicy()
  52    const eventKey = useMemo(() => getEventKey(event), [event])
  53    const replies = useThread(eventKey)
  54    const [showMuted, setShowMuted] = useState(false)
  55  
  56    // Keyboard navigation
  57    const { ref: navRef, isSelected } = useKeyboardNavigable(
  58      navColumn ?? 2,
  59      navIndex ?? 0,
  60      { meta: { type: 'note', event } }
  61    )
  62    const show = useMemo(() => {
  63      if (showMuted) {
  64        return true
  65      }
  66      if (mutePubkeySet.has(event.pubkey)) {
  67        return false
  68      }
  69      if (hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) {
  70        return false
  71      }
  72      return true
  73    }, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
  74    const hasReplies = useMemo(() => {
  75      if (!replies || replies.length === 0) {
  76        return false
  77      }
  78  
  79      for (const reply of replies) {
  80        if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
  81          continue
  82        }
  83        if (mutePubkeySet.has(reply.pubkey)) {
  84          continue
  85        }
  86        if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
  87          continue
  88        }
  89        return true
  90      }
  91    }, [replies])
  92  
  93    return (
  94      <div
  95        ref={navRef}
  96        className={cn(
  97          'relative pb-3 transition-colors duration-500 clickable scroll-mt-[6.5rem]',
  98          highlight ? 'bg-primary/40' : '',
  99          isSelected && 'ring-2 ring-primary ring-inset',
 100          className
 101        )}
 102        onClick={() => push(toNote(event))}
 103      >
 104        {hasReplies && <div className="absolute left-[34px] top-14 bottom-0 border-l z-20" />}
 105        <Collapsible>
 106          <div className="flex space-x-2 items-start px-4 pt-3">
 107            <UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-0.5" />
 108            <div className="w-full overflow-hidden">
 109              <div className="flex items-start justify-between gap-2">
 110                <div className="flex-1 w-0">
 111                  <div className="flex gap-1 items-center">
 112                    <Username
 113                      userId={event.pubkey}
 114                      className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
 115                      skeletonClassName="h-3"
 116                    />
 117                    <TrustScoreBadge pubkey={event.pubkey} className="!size-3.5" />
 118                    <ClientTag event={event} />
 119                  </div>
 120                  <div className="flex items-center gap-1 text-sm text-muted-foreground">
 121                    <Nip05 pubkey={event.pubkey} append="ยท" />
 122                    <FormattedTimestamp
 123                      timestamp={event.created_at}
 124                      className="shrink-0"
 125                      short={isSmallScreen}
 126                    />
 127                  </div>
 128                </div>
 129                <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
 130              </div>
 131              {parentEventId && (
 132                <ParentNotePreview
 133                  className="mt-2"
 134                  eventId={parentEventId}
 135                  onClick={(e) => {
 136                    e.stopPropagation()
 137                    onClickParent()
 138                  }}
 139                />
 140              )}
 141              {show ? (
 142                <Content className="mt-2" event={event} />
 143              ) : (
 144                <Button
 145                  variant="outline"
 146                  className="text-muted-foreground font-medium mt-2"
 147                  onClick={(e) => {
 148                    e.stopPropagation()
 149                    setShowMuted(true)
 150                  }}
 151                >
 152                  {t('Temporarily display this reply')}
 153                </Button>
 154              )}
 155            </div>
 156          </div>
 157        </Collapsible>
 158        {show && <StuffStats className="ml-14 pl-1 mr-4 mt-2" stuff={event} displayTopZapsAndLikes />}
 159      </div>
 160    )
 161  }
 162  
 163  export function ReplyNoteSkeleton() {
 164    return (
 165      <div className="px-4 py-3 flex items-start space-x-2 w-full">
 166        <Skeleton className="w-9 h-9 rounded-full shrink-0 mt-0.5" />
 167        <div className="w-full">
 168          <div className="py-1">
 169            <Skeleton className="h-3 w-16" />
 170          </div>
 171          <div className="my-1">
 172            <Skeleton className="w-full h-4 my-1 mt-2" />
 173          </div>
 174          <div className="my-1">
 175            <Skeleton className="w-2/3 h-4 my-1" />
 176          </div>
 177        </div>
 178      </div>
 179    )
 180  }
 181