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