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