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