feat: add profile menu item to sidebar
This commit is contained in:
50
src/components/Profile/FollowedBy.tsx
Normal file
50
src/components/Profile/FollowedBy.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function FollowedBy({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [followedBy, setFollowedBy] = useState<string[]>([])
|
||||
const { pubkey: accountPubkey } = useNostr()
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey || !accountPubkey) return
|
||||
|
||||
const init = async () => {
|
||||
const followings = (await client.fetchFollowings(accountPubkey)).reverse()
|
||||
const followingsOfFollowings = await Promise.all(
|
||||
followings.map(async (following) => {
|
||||
return client.fetchFollowings(following)
|
||||
})
|
||||
)
|
||||
const _followedBy: string[] = []
|
||||
const limit = isSmallScreen ? 3 : 5
|
||||
for (const [index, following] of followings.entries()) {
|
||||
if (following === pubkey) continue
|
||||
if (followingsOfFollowings[index].includes(pubkey)) {
|
||||
_followedBy.push(following)
|
||||
}
|
||||
if (_followedBy.length >= limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
setFollowedBy(_followedBy)
|
||||
}
|
||||
init()
|
||||
}, [pubkey, accountPubkey])
|
||||
|
||||
if (followedBy.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-muted-foreground">{t('Followed by')}</div>
|
||||
{followedBy.map((p) => (
|
||||
<UserAvatar userId={p} key={p} size="xSmall" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/components/Profile/Followings.tsx
Normal file
30
src/components/Profile/Followings.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useFetchFollowings } from '@/hooks'
|
||||
import { toFollowingList } from '@/lib/link'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { useFollowList } from '@/providers/FollowListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Followings({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey: accountPubkey } = useNostr()
|
||||
const { followings: selfFollowings } = useFollowList()
|
||||
const { followings, isFetching } = useFetchFollowings(pubkey)
|
||||
|
||||
return (
|
||||
<SecondaryPageLink
|
||||
to={toFollowingList(pubkey)}
|
||||
className="flex gap-1 hover:underline w-fit items-center"
|
||||
>
|
||||
{accountPubkey === pubkey ? (
|
||||
selfFollowings.length
|
||||
) : isFetching ? (
|
||||
<Loader className="animate-spin size-4" />
|
||||
) : (
|
||||
followings.length
|
||||
)}
|
||||
<div className="text-muted-foreground">{t('Following')}</div>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
114
src/components/Profile/ProfileFeed.tsx
Normal file
114
src/components/Profile/ProfileFeed.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import KindFilter from '@/components/KindFilter'
|
||||
import NoteList, { TNoteListRef } from '@/components/NoteList'
|
||||
import Tabs from '@/components/Tabs'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { useKindFilter } from '@/providers/KindFilterProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import { TFeedSubRequest, TNoteListMode } from '@/types'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export default function ProfileFeed({
|
||||
pubkey,
|
||||
topSpace = 0
|
||||
}: {
|
||||
pubkey: string
|
||||
topSpace?: number
|
||||
}) {
|
||||
const { pubkey: myPubkey } = useNostr()
|
||||
const { showKinds } = useKindFilter()
|
||||
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
||||
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
|
||||
const noteListRef = useRef<TNoteListRef>(null)
|
||||
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
|
||||
const tabs = useMemo(() => {
|
||||
const _tabs = [
|
||||
{ value: 'posts', label: 'Notes' },
|
||||
{ value: 'postsAndReplies', label: 'Replies' }
|
||||
]
|
||||
|
||||
if (myPubkey && myPubkey !== pubkey) {
|
||||
_tabs.push({ value: 'you', label: 'YouTabName' })
|
||||
}
|
||||
|
||||
return _tabs
|
||||
}, [myPubkey, pubkey])
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (listMode === 'you') {
|
||||
if (!myPubkey) {
|
||||
setSubRequests([])
|
||||
return
|
||||
}
|
||||
|
||||
const [relayList, myRelayList] = await Promise.all([
|
||||
client.fetchRelayList(pubkey),
|
||||
client.fetchRelayList(myPubkey)
|
||||
])
|
||||
|
||||
setSubRequests([
|
||||
{
|
||||
urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
|
||||
filter: {
|
||||
authors: [myPubkey],
|
||||
'#p': [pubkey]
|
||||
}
|
||||
},
|
||||
{
|
||||
urls: relayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
|
||||
filter: {
|
||||
authors: [pubkey],
|
||||
'#p': [myPubkey]
|
||||
}
|
||||
}
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
setSubRequests([
|
||||
{
|
||||
urls: relayList.write.concat(BIG_RELAY_URLS).slice(0, 8),
|
||||
filter: {
|
||||
authors: [pubkey]
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
init()
|
||||
}, [pubkey, listMode])
|
||||
|
||||
const handleListModeChange = (mode: TNoteListMode) => {
|
||||
setListMode(mode)
|
||||
noteListRef.current?.scrollToTop('smooth')
|
||||
}
|
||||
|
||||
const handleShowKindsChange = (newShowKinds: number[]) => {
|
||||
setTemporaryShowKinds(newShowKinds)
|
||||
noteListRef.current?.scrollToTop()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
value={listMode}
|
||||
tabs={tabs}
|
||||
onTabChange={(listMode) => {
|
||||
handleListModeChange(listMode as TNoteListMode)
|
||||
}}
|
||||
threshold={Math.max(800, topSpace)}
|
||||
options={
|
||||
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
|
||||
}
|
||||
/>
|
||||
<NoteList
|
||||
ref={noteListRef}
|
||||
subRequests={subRequests}
|
||||
showKinds={temporaryShowKinds}
|
||||
hideReplies={listMode === 'posts'}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
src/components/Profile/Relays.tsx
Normal file
22
src/components/Profile/Relays.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useFetchRelayList } from '@/hooks'
|
||||
import { toOthersRelaySettings, toRelaySettings } from '@/lib/link'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Relays({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey: accountPubkey } = useNostr()
|
||||
const { relayList, isFetching } = useFetchRelayList(pubkey)
|
||||
|
||||
return (
|
||||
<SecondaryPageLink
|
||||
to={accountPubkey === pubkey ? toRelaySettings('mailbox') : toOthersRelaySettings(pubkey)}
|
||||
className="flex gap-1 hover:underline w-fit items-center"
|
||||
>
|
||||
{isFetching ? <Loader className="animate-spin size-4" /> : relayList.originalRelays.length}
|
||||
<div className="text-muted-foreground">{t('Relays')}</div>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
191
src/components/Profile/index.tsx
Normal file
191
src/components/Profile/index.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import Collapsible from '@/components/Collapsible'
|
||||
import FollowButton from '@/components/FollowButton'
|
||||
import Nip05 from '@/components/Nip05'
|
||||
import NpubQrCode from '@/components/NpubQrCode'
|
||||
import ProfileAbout from '@/components/ProfileAbout'
|
||||
import ProfileBanner from '@/components/ProfileBanner'
|
||||
import ProfileOptions from '@/components/ProfileOptions'
|
||||
import ProfileZapButton from '@/components/ProfileZapButton'
|
||||
import PubkeyCopy from '@/components/PubkeyCopy'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
||||
import { toMuteList, toProfileEditor } from '@/lib/link'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Link, Zap } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FollowedBy from './FollowedBy'
|
||||
import Followings from './Followings'
|
||||
import ProfileFeed from './ProfileFeed'
|
||||
import Relays from './Relays'
|
||||
|
||||
export default function Profile({ id }: { id?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { profile, isFetching } = useFetchProfile(id)
|
||||
const { pubkey: accountPubkey } = useNostr()
|
||||
const { mutePubkeys } = useMuteList()
|
||||
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||
const isFollowingYou = useMemo(() => {
|
||||
return (
|
||||
!!accountPubkey && accountPubkey !== profile?.pubkey && followings.includes(accountPubkey)
|
||||
)
|
||||
}, [followings, profile, accountPubkey])
|
||||
const defaultImage = useMemo(
|
||||
() => (profile?.pubkey ? generateImageByPubkey(profile?.pubkey) : ''),
|
||||
[profile]
|
||||
)
|
||||
const [topContainerHeight, setTopContainerHeight] = useState(0)
|
||||
const isSelf = accountPubkey === profile?.pubkey
|
||||
const [topContainer, setTopContainer] = useState<HTMLDivElement | null>(null)
|
||||
const topContainerRef = useCallback((node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
setTopContainer(node)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile?.pubkey) return
|
||||
|
||||
const forceUpdateCache = async () => {
|
||||
await Promise.all([
|
||||
client.forceUpdateRelayListEvent(profile.pubkey),
|
||||
client.fetchProfile(profile.pubkey, true)
|
||||
])
|
||||
}
|
||||
forceUpdateCache()
|
||||
}, [profile?.pubkey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!topContainer) return
|
||||
|
||||
const checkHeight = () => {
|
||||
setTopContainerHeight(topContainer.scrollHeight)
|
||||
}
|
||||
|
||||
checkHeight()
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
checkHeight()
|
||||
})
|
||||
|
||||
observer.observe(topContainer)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [topContainer])
|
||||
|
||||
if (!profile && isFetching) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="relative bg-cover bg-center mb-2">
|
||||
<Skeleton className="w-full aspect-[3/1] rounded-none" />
|
||||
<Skeleton className="w-24 h-24 absolute bottom-0 left-3 translate-y-1/2 border-4 border-background rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<Skeleton className="h-5 w-28 mt-14 mb-1" />
|
||||
<Skeleton className="h-5 w-56 mt-2 my-1 rounded-full" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (!profile) return null
|
||||
|
||||
const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile
|
||||
return (
|
||||
<>
|
||||
<div ref={topContainerRef}>
|
||||
<div className="relative bg-cover bg-center mb-2">
|
||||
<ProfileBanner banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" />
|
||||
<Avatar className="w-24 h-24 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background">
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultImage} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<div className="flex justify-end h-8 gap-2 items-center">
|
||||
<ProfileOptions pubkey={pubkey} />
|
||||
{isSelf ? (
|
||||
<Button
|
||||
className="w-20 min-w-20 rounded-full"
|
||||
variant="secondary"
|
||||
onClick={() => push(toProfileEditor())}
|
||||
>
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{!!lightningAddress && <ProfileZapButton pubkey={pubkey} />}
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="text-xl font-semibold truncate select-text">{username}</div>
|
||||
{isFollowingYou && (
|
||||
<div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2 shrink-0">
|
||||
{t('Follows you')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Nip05 pubkey={pubkey} />
|
||||
{lightningAddress && (
|
||||
<div className="text-sm text-yellow-400 flex gap-1 items-center select-text">
|
||||
<Zap className="size-4 shrink-0" />
|
||||
<div className="flex-1 max-w-fit w-0 truncate">{lightningAddress}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1 mt-1">
|
||||
<PubkeyCopy pubkey={pubkey} />
|
||||
<NpubQrCode pubkey={pubkey} />
|
||||
</div>
|
||||
<Collapsible>
|
||||
<ProfileAbout
|
||||
about={about}
|
||||
className="text-wrap break-words whitespace-pre-wrap mt-2 select-text"
|
||||
/>
|
||||
</Collapsible>
|
||||
{website && (
|
||||
<div className="flex gap-1 items-center text-primary mt-2 truncate select-text">
|
||||
<Link size={14} className="shrink-0" />
|
||||
<a
|
||||
href={website}
|
||||
target="_blank"
|
||||
className="hover:underline truncate flex-1 max-w-fit w-0"
|
||||
>
|
||||
{website}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center mt-2 text-sm">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Followings pubkey={pubkey} />
|
||||
<Relays pubkey={pubkey} />
|
||||
{isSelf && (
|
||||
<SecondaryPageLink to={toMuteList()} className="flex gap-1 hover:underline w-fit">
|
||||
{mutePubkeys.length}
|
||||
<div className="text-muted-foreground">{t('Muted')}</div>
|
||||
</SecondaryPageLink>
|
||||
)}
|
||||
</div>
|
||||
{!isSelf && <FollowedBy pubkey={pubkey} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileFeed pubkey={pubkey} topSpace={topContainerHeight + 100} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
src/components/Sidebar/ProfileButton.tsx
Normal file
19
src/components/Sidebar/ProfileButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { UserRound } from 'lucide-react'
|
||||
import SidebarItem from './SidebarItem'
|
||||
|
||||
export default function ProfileButton() {
|
||||
const { navigate, current } = usePrimaryPage()
|
||||
const { checkLogin } = useNostr()
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
title="Profile"
|
||||
onClick={() => checkLogin(() => navigate('profile'))}
|
||||
active={current === 'profile'}
|
||||
>
|
||||
<UserRound strokeWidth={3} />
|
||||
</SidebarItem>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import RelaysButton from './ExploreButton'
|
||||
import HomeButton from './HomeButton'
|
||||
import NotificationsButton from './NotificationButton'
|
||||
import PostButton from './PostButton'
|
||||
import ProfileButton from './ProfileButton'
|
||||
import SearchButton from './SearchButton'
|
||||
import SettingsButton from './SettingsButton'
|
||||
|
||||
@@ -24,6 +25,7 @@ export default function PrimaryPageSidebar() {
|
||||
<RelaysButton />
|
||||
<NotificationsButton />
|
||||
<SearchButton />
|
||||
<ProfileButton />
|
||||
<SettingsButton />
|
||||
<PostButton />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user