index.tsx raw

   1  import { useSecondaryPage } from '@/PageManager'
   2  import ContentPreview from '@/components/ContentPreview'
   3  import Note from '@/components/Note'
   4  import NoteInteractions from '@/components/NoteInteractions'
   5  import StuffStats from '@/components/StuffStats'
   6  import UserAvatar from '@/components/UserAvatar'
   7  import { Card } from '@/components/ui/card'
   8  import { Separator } from '@/components/ui/separator'
   9  import { Skeleton } from '@/components/ui/skeleton'
  10  import { ExtendedKind } from '@/constants'
  11  import { useFetchEvent } from '@/hooks'
  12  import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
  13  import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
  14  import {
  15    getEventKey,
  16    getKeyFromTag,
  17    getParentBech32Id,
  18    getParentTag,
  19    getRootBech32Id
  20  } from '@/lib/event'
  21  import { toExternalContent, toNote } from '@/lib/link'
  22  import { tagNameEquals } from '@/lib/tag'
  23  import { cn } from '@/lib/utils'
  24  import { Ellipsis } from 'lucide-react'
  25  import { Event } from 'nostr-tools'
  26  import { forwardRef, useCallback, useMemo } from 'react'
  27  import { useTranslation } from 'react-i18next'
  28  import NotFound from './NotFound'
  29  
  30  const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
  31    const { t } = useTranslation()
  32    const { event, isFetching } = useFetchEvent(id)
  33    const parentEventId = useMemo(() => getParentBech32Id(event), [event])
  34    const rootEventId = useMemo(() => getRootBech32Id(event), [event])
  35    const rootITag = useMemo(
  36      () => (event?.kind === ExtendedKind.COMMENT ? event.tags.find(tagNameEquals('I')) : undefined),
  37      [event]
  38    )
  39    const { isFetching: isFetchingRootEvent, event: rootEvent } = useFetchEvent(rootEventId)
  40    const { isFetching: isFetchingParentEvent, event: parentEvent } = useFetchEvent(parentEventId)
  41  
  42    if (!event && isFetching) {
  43      return (
  44        <SecondaryPageLayout ref={ref} index={index} title={t('Note')}>
  45          <div className="px-4 pt-3">
  46            <div className="flex items-center space-x-2">
  47              <Skeleton className="w-10 h-10 rounded-full" />
  48              <div className={`flex-1 w-0`}>
  49                <div className="py-1">
  50                  <Skeleton className="h-4 w-16" />
  51                </div>
  52                <div className="py-0.5">
  53                  <Skeleton className="h-4 w-12" />
  54                </div>
  55              </div>
  56            </div>
  57            <div className="pt-2">
  58              <div className="my-1">
  59                <Skeleton className="w-full h-4 my-1 mt-2" />
  60              </div>
  61              <div className="my-1">
  62                <Skeleton className="w-2/3 h-4 my-1" />
  63              </div>
  64            </div>
  65          </div>
  66        </SecondaryPageLayout>
  67      )
  68    }
  69    if (!event) {
  70      return (
  71        <SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
  72          <NotFound bech32Id={id} />
  73        </SecondaryPageLayout>
  74      )
  75    }
  76  
  77    // Calculate navIndex offset for replies based on how many parent notes exist
  78    const hasRootNote = rootEventId && rootEventId !== parentEventId
  79    const hasParentNote = !!parentEventId
  80    const parentNoteCount = (hasRootNote ? 1 : 0) + (hasParentNote ? 1 : 0)
  81  
  82    return (
  83      <SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
  84        <div className="px-4 pt-3">
  85          {rootITag && <ExternalRoot value={rootITag[1]} />}
  86          {hasRootNote && (
  87            <ParentNote
  88              key={`root-note-${event.id}`}
  89              isFetching={isFetchingRootEvent}
  90              event={rootEvent}
  91              eventBech32Id={rootEventId}
  92              isConsecutive={isConsecutive(rootEvent, parentEvent)}
  93              navIndex={0}
  94            />
  95          )}
  96          {hasParentNote && (
  97            <ParentNote
  98              key={`parent-note-${event.id}`}
  99              isFetching={isFetchingParentEvent}
 100              event={parentEvent}
 101              eventBech32Id={parentEventId}
 102              navIndex={hasRootNote ? 1 : 0}
 103            />
 104          )}
 105          <Note
 106            key={`note-${event.id}`}
 107            event={event}
 108            className="select-text"
 109            hideParentNotePreview
 110            originalNoteId={id}
 111            showFull
 112          />
 113          <StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
 114        </div>
 115        <Separator className="mt-4" />
 116        <NoteInteractions key={`note-interactions-${event.id}`} event={event} navIndexOffset={parentNoteCount} />
 117      </SecondaryPageLayout>
 118    )
 119  })
 120  NotePage.displayName = 'NotePage'
 121  export default NotePage
 122  
 123  function ExternalRoot({ value }: { value: string }) {
 124    const { push } = useSecondaryPage()
 125  
 126    return (
 127      <div>
 128        <Card
 129          className="flex space-x-1 px-1.5 py-1 items-center clickable text-sm text-muted-foreground hover:text-foreground"
 130          onClick={() => push(toExternalContent(value))}
 131        >
 132          <div className="truncate">{value}</div>
 133        </Card>
 134        <div className="ml-5 w-px h-2 bg-border" />
 135      </div>
 136    )
 137  }
 138  
 139  function ParentNote({
 140    event,
 141    eventBech32Id,
 142    isFetching,
 143    isConsecutive = true,
 144    navIndex
 145  }: {
 146    event?: Event
 147    eventBech32Id: string
 148    isFetching: boolean
 149    isConsecutive?: boolean
 150    navIndex?: number
 151  }) {
 152    const { push } = useSecondaryPage()
 153  
 154    const handleActivate = useCallback(() => {
 155      push(toNote(event ?? eventBech32Id))
 156    }, [push, event, eventBech32Id])
 157  
 158    const { ref: navRef, isSelected } = useKeyboardNavigable(2, navIndex ?? 0, {
 159      meta: { type: 'note', onActivate: handleActivate }
 160    })
 161  
 162    if (isFetching) {
 163      return (
 164        <div>
 165          <div className="flex space-x-1 px-[0.4375rem] py-1 items-center rounded-full border clickable text-sm text-muted-foreground">
 166            <Skeleton className="shrink w-4 h-4 rounded-full" />
 167            <div className="py-1 flex-1">
 168              <Skeleton className="h-3" />
 169            </div>
 170          </div>
 171          <div className="ml-5 w-px h-3 bg-border" />
 172        </div>
 173      )
 174    }
 175  
 176    return (
 177      <div ref={navRef} className="scroll-mt-[6.5rem]">
 178        <div
 179          className={cn(
 180            'flex space-x-1 px-[0.4375rem] py-1 items-center rounded-full border clickable text-sm text-muted-foreground',
 181            event && 'hover:text-foreground',
 182            isSelected && 'ring-2 ring-primary'
 183          )}
 184          onClick={handleActivate}
 185        >
 186          {event && <UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />}
 187          <ContentPreview className="truncate" event={event} />
 188        </div>
 189        {isConsecutive ? (
 190          <div className="ml-5 w-px h-3 bg-border" />
 191        ) : (
 192          <Ellipsis className="ml-3.5 text-muted-foreground/60 size-3" />
 193        )}
 194      </div>
 195    )
 196  }
 197  
 198  function isConsecutive(rootEvent?: Event, parentEvent?: Event) {
 199    if (!rootEvent || !parentEvent) return false
 200  
 201    const tag = getParentTag(parentEvent)
 202    if (!tag) return false
 203  
 204    return getEventKey(rootEvent) === getKeyFromTag(tag.tag)
 205  }
 206