index.tsx raw

   1  import Collapsible from '@/components/Collapsible'
   2  import FollowButton from '@/components/FollowButton'
   3  import Nip05 from '@/components/Nip05'
   4  import NpubQrCode from '@/components/NpubQrCode'
   5  import ProfileAbout from '@/components/ProfileAbout'
   6  import ProfileOptions from '@/components/ProfileOptions'
   7  import ProfileZapButton from '@/components/ProfileZapButton'
   8  import PubkeyCopy from '@/components/PubkeyCopy'
   9  import { Button } from '@/components/ui/button'
  10  import { Skeleton } from '@/components/ui/skeleton'
  11  import { useFetchFollowings, useFetchProfile } from '@/hooks'
  12  import { toMuteList, toProfileEditor } from '@/lib/link'
  13  import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
  14  import { useMuteList } from '@/providers/MuteListProvider'
  15  import { useNostr } from '@/providers/NostrProvider'
  16  import client from '@/services/client.service'
  17  import { Link, Zap } from 'lucide-react'
  18  import { useCallback, useEffect, useMemo, useState } from 'react'
  19  import { useTranslation } from 'react-i18next'
  20  import NotFound from '../NotFound'
  21  import SearchInput from '../SearchInput'
  22  import TextWithEmojis from '../TextWithEmojis'
  23  import TrustScoreBadge from '../TrustScoreBadge'
  24  import AvatarWithLightbox from './AvatarWithLightbox'
  25  import BannerWithLightbox from './BannerWithLightbox'
  26  import FollowedBy from './FollowedBy'
  27  import Followings from './Followings'
  28  import ProfileFeed from './ProfileFeed'
  29  import Relays from './Relays'
  30  import SpecialFollowButton from './SpecialFollowButton'
  31  
  32  export default function Profile({ id }: { id?: string }) {
  33    const { t } = useTranslation()
  34    const { push } = useSecondaryPage()
  35    const { profile, isFetching } = useFetchProfile(id)
  36    const { pubkey: accountPubkey } = useNostr()
  37    const { mutePubkeySet } = useMuteList()
  38    const [searchInput, setSearchInput] = useState('')
  39    const [debouncedInput, setDebouncedInput] = useState(searchInput)
  40    const { followings } = useFetchFollowings(profile?.pubkey)
  41    const isFollowingYou = useMemo(() => {
  42      return (
  43        !!accountPubkey && accountPubkey !== profile?.pubkey && followings.includes(accountPubkey)
  44      )
  45    }, [followings, profile, accountPubkey])
  46    const [topContainerHeight, setTopContainerHeight] = useState(0)
  47    const isSelf = accountPubkey === profile?.pubkey
  48    const [topContainer, setTopContainer] = useState<HTMLDivElement | null>(null)
  49    const topContainerRef = useCallback((node: HTMLDivElement | null) => {
  50      if (node) {
  51        setTopContainer(node)
  52      }
  53    }, [])
  54  
  55    useEffect(() => {
  56      const handler = setTimeout(() => {
  57        setDebouncedInput(searchInput.trim())
  58      }, 1000)
  59  
  60      return () => {
  61        clearTimeout(handler)
  62      }
  63    }, [searchInput])
  64  
  65    useEffect(() => {
  66      if (!profile?.pubkey) return
  67  
  68      const forceUpdateCache = async () => {
  69        await Promise.all([
  70          client.forceUpdateRelayListEvent(profile.pubkey),
  71          client.fetchProfile(profile.pubkey, true)
  72        ])
  73      }
  74      forceUpdateCache()
  75    }, [profile?.pubkey])
  76  
  77    useEffect(() => {
  78      if (!topContainer) return
  79  
  80      const checkHeight = () => {
  81        setTopContainerHeight(topContainer.scrollHeight)
  82      }
  83  
  84      checkHeight()
  85  
  86      const observer = new ResizeObserver(() => {
  87        checkHeight()
  88      })
  89  
  90      observer.observe(topContainer)
  91  
  92      return () => {
  93        observer.disconnect()
  94      }
  95    }, [topContainer])
  96  
  97    if (!profile && isFetching) {
  98      return (
  99        <>
 100          <div>
 101            <div className="relative bg-cover bg-center mb-2">
 102              <Skeleton className="w-full aspect-[3/1] rounded-none" />
 103              <Skeleton className="w-24 h-24 absolute bottom-0 left-3 translate-y-1/2 border-4 border-background rounded-full" />
 104            </div>
 105          </div>
 106          <div className="px-4">
 107            <Skeleton className="h-5 w-28 mt-14 mb-1" />
 108            <Skeleton className="h-5 w-56 mt-2 my-1 rounded-full" />
 109          </div>
 110        </>
 111      )
 112    }
 113    if (!profile) return <NotFound />
 114  
 115    const { banner, username, about, pubkey, website, lightningAddress, emojis } = profile
 116    return (
 117      <>
 118        <div ref={topContainerRef}>
 119          <div className="relative bg-cover bg-center mb-2">
 120            <BannerWithLightbox banner={banner} pubkey={pubkey} />
 121            <AvatarWithLightbox userId={pubkey} />
 122          </div>
 123          <div className="px-4">
 124            <div className="flex justify-end h-8 gap-2 items-center">
 125              <ProfileOptions pubkey={pubkey} />
 126              {isSelf ? (
 127                <Button
 128                  className="w-20 min-w-20 rounded-full"
 129                  variant="secondary"
 130                  onClick={() => push(toProfileEditor())}
 131                >
 132                  {t('Edit')}
 133                </Button>
 134              ) : (
 135                <>
 136                  {!!lightningAddress && <ProfileZapButton pubkey={pubkey} />}
 137                  <SpecialFollowButton pubkey={pubkey} />
 138                  <FollowButton pubkey={pubkey} />
 139                </>
 140              )}
 141            </div>
 142            <div className="pt-2">
 143              <div className="flex gap-2 items-center">
 144                <TextWithEmojis
 145                  text={username}
 146                  emojis={emojis}
 147                  className="text-xl font-semibold truncate select-text"
 148                />
 149                <TrustScoreBadge pubkey={pubkey} />
 150                {isFollowingYou && (
 151                  <div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2 shrink-0">
 152                    {t('Follows you')}
 153                  </div>
 154                )}
 155              </div>
 156              <Nip05 pubkey={pubkey} />
 157              {lightningAddress && (
 158                <div className="text-sm text-yellow-400 flex gap-1 items-center select-text">
 159                  <Zap className="size-4 shrink-0" />
 160                  <div className="flex-1 max-w-fit w-0 truncate">{lightningAddress}</div>
 161                </div>
 162              )}
 163              <div className="flex gap-1 mt-1">
 164                <PubkeyCopy pubkey={pubkey} />
 165                <NpubQrCode pubkey={pubkey} />
 166              </div>
 167              <Collapsible>
 168                <ProfileAbout
 169                  about={about}
 170                  emojis={emojis}
 171                  className="text-wrap break-words whitespace-pre-wrap mt-2 select-text"
 172                />
 173              </Collapsible>
 174              {website && (
 175                <div className="flex gap-1 items-center text-primary mt-2 truncate select-text">
 176                  <Link size={14} className="shrink-0" />
 177                  <a
 178                    href={website}
 179                    target="_blank"
 180                    className="hover:underline truncate flex-1 max-w-fit w-0"
 181                  >
 182                    {website}
 183                  </a>
 184                </div>
 185              )}
 186              <div className="flex justify-between items-center mt-2 text-sm">
 187                <div className="flex gap-4 items-center">
 188                  <Followings pubkey={pubkey} />
 189                  <Relays pubkey={pubkey} />
 190                  {isSelf && (
 191                    <SecondaryPageLink to={toMuteList()} className="flex gap-1 hover:underline w-fit">
 192                      {mutePubkeySet.size}
 193                      <div className="text-muted-foreground">{t('Muted')}</div>
 194                    </SecondaryPageLink>
 195                  )}
 196                </div>
 197                {!isSelf && <FollowedBy pubkey={pubkey} />}
 198              </div>
 199            </div>
 200          </div>
 201          <div className="px-4 pt-3.5 pb-0.5">
 202            <SearchInput
 203              value={searchInput}
 204              onChange={(e) => setSearchInput(e.target.value)}
 205              placeholder={t('Search')}
 206            />
 207          </div>
 208        </div>
 209        <ProfileFeed pubkey={pubkey} topSpace={topContainerHeight + 100} search={debouncedInput} />
 210      </>
 211    )
 212  }
 213