feat: follow button
This commit is contained in:
77
src/renderer/src/components/FollowButton/index.tsx
Normal file
77
src/renderer/src/components/FollowButton/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { TDraftEvent } from '@common/types'
|
||||||
|
import { Button } from '@renderer/components/ui/button'
|
||||||
|
import { useFetchFollowings } from '@renderer/hooks'
|
||||||
|
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { Loader } from 'lucide-react'
|
||||||
|
import { kinds } from 'nostr-tools'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
export default function FollowButton({ pubkey }: { pubkey: string }) {
|
||||||
|
const { pubkey: accountPubkey, publish } = useNostr()
|
||||||
|
const { followings, followListEvent, refresh } = useFetchFollowings(accountPubkey)
|
||||||
|
const [updating, setUpdating] = useState(false)
|
||||||
|
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
|
||||||
|
|
||||||
|
if (!accountPubkey || pubkey === accountPubkey) return null
|
||||||
|
|
||||||
|
const follow = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isFollowing) return
|
||||||
|
|
||||||
|
setUpdating(true)
|
||||||
|
const newFollowListEvent: TDraftEvent = {
|
||||||
|
kind: kinds.Contacts,
|
||||||
|
content: followListEvent?.content ?? '',
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
tags: (followListEvent?.tags ?? []).concat([['p', pubkey]])
|
||||||
|
}
|
||||||
|
console.log(newFollowListEvent)
|
||||||
|
try {
|
||||||
|
await publish(newFollowListEvent)
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
setUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unfollow = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!isFollowing || !followListEvent) return
|
||||||
|
|
||||||
|
setUpdating(true)
|
||||||
|
const newFollowListEvent: TDraftEvent = {
|
||||||
|
kind: kinds.Contacts,
|
||||||
|
content: followListEvent.content ?? '',
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
tags: followListEvent.tags.filter(
|
||||||
|
([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await publish(newFollowListEvent)
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
setUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isFollowing ? (
|
||||||
|
<Button
|
||||||
|
className="w-20 min-w-20 rounded-full"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={unfollow}
|
||||||
|
disabled={updating}
|
||||||
|
>
|
||||||
|
{updating ? <Loader className="animate-spin" /> : 'Unfollow'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="w-20 min-w-20 rounded-full" onClick={follow} disabled={updating}>
|
||||||
|
{updating ? <Loader className="animate-spin" /> : 'Follow'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/renderer/src/components/ProfileBanner/index.tsx
Normal file
32
src/renderer/src/components/ProfileBanner/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
export default function ProfileBanner({
|
||||||
|
pubkey,
|
||||||
|
banner,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
pubkey: string
|
||||||
|
banner?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const defaultBanner = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
||||||
|
const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (banner) {
|
||||||
|
setBannerUrl(banner)
|
||||||
|
} else {
|
||||||
|
setBannerUrl(defaultBanner)
|
||||||
|
}
|
||||||
|
}, [defaultBanner, banner])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={bannerUrl}
|
||||||
|
alt={`${pubkey} banner`}
|
||||||
|
className={className}
|
||||||
|
onError={() => setBannerUrl(defaultBanner)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,27 +2,29 @@ import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/ava
|
|||||||
import { useFetchProfile } from '@renderer/hooks'
|
import { useFetchProfile } from '@renderer/hooks'
|
||||||
import { generateImageByPubkey } from '@renderer/lib/pubkey'
|
import { generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import FollowButton from '../FollowButton'
|
||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
import ProfileAbout from '../ProfileAbout'
|
import ProfileAbout from '../ProfileAbout'
|
||||||
|
|
||||||
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
||||||
const { avatar = '', username, nip05, about } = useFetchProfile(pubkey)
|
const { avatar = '', username, nip05, about } = useFetchProfile(pubkey)
|
||||||
const defaultAvatar = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
<div className="flex space-x-2 w-full items-center">
|
<div className="flex space-x-2 w-full items-start justify-between">
|
||||||
<Avatar className="w-12 h-12">
|
<Avatar className="w-12 h-12">
|
||||||
<AvatarImage src={avatar} />
|
<AvatarImage className="object-cover object-center" src={avatar} />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
<img src={defaultAvatar} alt={pubkey} />
|
<img src={defaultImage} alt={pubkey} />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 w-0">
|
<FollowButton pubkey={pubkey} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<div className="text-lg font-semibold truncate">{username}</div>
|
<div className="text-lg font-semibold truncate">{username}</div>
|
||||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{about && (
|
{about && (
|
||||||
<div
|
<div
|
||||||
className="text-sm text-wrap break-words w-full overflow-hidden text-ellipsis"
|
className="text-sm text-wrap break-words w-full overflow-hidden text-ellipsis"
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import { tagNameEquals } from '@renderer/lib/tag'
|
import { tagNameEquals } from '@renderer/lib/tag'
|
||||||
import client from '@renderer/services/client.service'
|
import client from '@renderer/services/client.service'
|
||||||
import { kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
export function useFetchFollowings(pubkey?: string) {
|
export function useFetchFollowings(pubkey?: string | null) {
|
||||||
|
const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
|
||||||
const [followings, setFollowings] = useState<string[]>([])
|
const [followings, setFollowings] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
if (!pubkey) return
|
if (!pubkey) return
|
||||||
|
|
||||||
const followListEvent = await client.fetchEventByFilter({
|
const event = await client.fetchEventByFilter({
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
kinds: [kinds.Contacts],
|
kinds: [kinds.Contacts]
|
||||||
limit: 1
|
|
||||||
})
|
})
|
||||||
if (!followListEvent) return
|
if (!event) return
|
||||||
|
|
||||||
|
setFollowListEvent(event)
|
||||||
setFollowings(
|
setFollowings(
|
||||||
followListEvent.tags
|
event.tags
|
||||||
.filter(tagNameEquals('p'))
|
.filter(tagNameEquals('p'))
|
||||||
.map(([, pubkey]) => pubkey)
|
.map(([, pubkey]) => pubkey)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -29,5 +30,27 @@ export function useFetchFollowings(pubkey?: string) {
|
|||||||
init()
|
init()
|
||||||
}, [pubkey])
|
}, [pubkey])
|
||||||
|
|
||||||
return followings
|
const refresh = async () => {
|
||||||
|
if (!pubkey) return
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
authors: [pubkey],
|
||||||
|
kinds: [kinds.Contacts]
|
||||||
|
}
|
||||||
|
|
||||||
|
client.deleteEventCacheByFilter(filter)
|
||||||
|
const event = await client.fetchEventByFilter(filter)
|
||||||
|
if (!event) return
|
||||||
|
|
||||||
|
setFollowListEvent(event)
|
||||||
|
setFollowings(
|
||||||
|
event.tags
|
||||||
|
.filter(tagNameEquals('p'))
|
||||||
|
.map(([, pubkey]) => pubkey)
|
||||||
|
.filter(Boolean)
|
||||||
|
.reverse()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { followings, followListEvent, refresh }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import FollowButton from '@renderer/components/FollowButton'
|
||||||
import Nip05 from '@renderer/components/Nip05'
|
import Nip05 from '@renderer/components/Nip05'
|
||||||
import UserAvatar from '@renderer/components/UserAvatar'
|
import UserAvatar from '@renderer/components/UserAvatar'
|
||||||
import Username from '@renderer/components/Username'
|
import Username from '@renderer/components/Username'
|
||||||
@@ -7,7 +8,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
|
|
||||||
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||||
const { username } = useFetchProfile(pubkey)
|
const { username } = useFetchProfile(pubkey)
|
||||||
const followings = useFetchFollowings(pubkey)
|
const { followings } = useFetchFollowings(pubkey)
|
||||||
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
||||||
const observer = useRef<IntersectionObserver | null>(null)
|
const observer = useRef<IntersectionObserver | null>(null)
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -47,7 +48,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
|||||||
<SecondaryPageLayout titlebarContent={username ? `${username}'s following` : 'following'}>
|
<SecondaryPageLayout titlebarContent={username ? `${username}'s following` : 'following'}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{visibleFollowings.map((pubkey, index) => (
|
{visibleFollowings.map((pubkey, index) => (
|
||||||
<FollowingItem key={index} pubkey={pubkey} />
|
<FollowingItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||||
))}
|
))}
|
||||||
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
|
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -64,8 +65,9 @@ function FollowingItem({ pubkey }: { pubkey: string }) {
|
|||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
<Username userId={pubkey} className="font-semibold truncate" />
|
<Username userId={pubkey} className="font-semibold truncate" />
|
||||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||||
<div className="truncate text-muted-foreground">{about}</div>
|
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FollowButton pubkey={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import FollowButton from '@renderer/components/FollowButton'
|
||||||
import Nip05 from '@renderer/components/Nip05'
|
import Nip05 from '@renderer/components/Nip05'
|
||||||
import NoteList from '@renderer/components/NoteList'
|
import NoteList from '@renderer/components/NoteList'
|
||||||
import ProfileAbout from '@renderer/components/ProfileAbout'
|
import ProfileAbout from '@renderer/components/ProfileAbout'
|
||||||
|
import ProfileBanner from '@renderer/components/ProfileBanner'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
|
||||||
import { Separator } from '@renderer/components/ui/separator'
|
import { Separator } from '@renderer/components/ui/separator'
|
||||||
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
||||||
@@ -9,15 +11,21 @@ import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
|||||||
import { toFollowingList } from '@renderer/lib/link'
|
import { toFollowingList } from '@renderer/lib/link'
|
||||||
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
|
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||||
import { SecondaryPageLink } from '@renderer/PageManager'
|
import { SecondaryPageLink } from '@renderer/PageManager'
|
||||||
|
import { useNostr } from '@renderer/providers/NostrProvider'
|
||||||
import { Copy } from 'lucide-react'
|
import { Copy } from 'lucide-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||||
const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey)
|
const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey)
|
||||||
const relayList = useFetchRelayList(pubkey)
|
const relayList = useFetchRelayList(pubkey)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const followings = useFetchFollowings(pubkey)
|
const { pubkey: accountPubkey } = useNostr()
|
||||||
|
const { followings } = useFetchFollowings(pubkey)
|
||||||
|
const isFollowingYou = useMemo(
|
||||||
|
() => !!accountPubkey && accountPubkey !== pubkey && followings.includes(accountPubkey),
|
||||||
|
[followings, pubkey]
|
||||||
|
)
|
||||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : undefined), [pubkey])
|
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : undefined), [pubkey])
|
||||||
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
||||||
|
|
||||||
@@ -31,10 +39,9 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={username}>
|
<SecondaryPageLayout titlebarContent={username}>
|
||||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-12">
|
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||||
<ProfileBanner
|
<ProfileBanner
|
||||||
banner={banner}
|
banner={banner}
|
||||||
defaultBanner={defaultImage}
|
|
||||||
pubkey={pubkey}
|
pubkey={pubkey}
|
||||||
className="w-full h-full object-cover rounded-lg"
|
className="w-full h-full object-cover rounded-lg"
|
||||||
/>
|
/>
|
||||||
@@ -45,7 +52,15 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4">
|
<div className="flex justify-end h-8 gap-2 items-center">
|
||||||
|
{isFollowingYou && (
|
||||||
|
<div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2">
|
||||||
|
Follows you
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FollowButton pubkey={pubkey} />
|
||||||
|
</div>
|
||||||
|
<div className="pt-2">
|
||||||
<div className="text-xl font-semibold">{username}</div>
|
<div className="text-xl font-semibold">{username}</div>
|
||||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
||||||
<div
|
<div
|
||||||
@@ -81,34 +96,3 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
|||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileBanner({
|
|
||||||
defaultBanner,
|
|
||||||
pubkey,
|
|
||||||
banner,
|
|
||||||
className
|
|
||||||
}: {
|
|
||||||
defaultBanner: string
|
|
||||||
pubkey: string
|
|
||||||
banner?: string
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (banner) {
|
|
||||||
setBannerUrl(banner)
|
|
||||||
} else {
|
|
||||||
setBannerUrl(defaultBanner)
|
|
||||||
}
|
|
||||||
}, [defaultBanner, banner])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={bannerUrl}
|
|
||||||
alt={`${pubkey} banner`}
|
|
||||||
className={className}
|
|
||||||
onError={() => setBannerUrl(defaultBanner)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ class ClientService {
|
|||||||
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
|
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteEventCacheByFilter(filter: Filter) {
|
||||||
|
this.eventCache.delete(JSON.stringify({ ...filter, limit: 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
async fetchEventById(id: string): Promise<NEvent | undefined> {
|
async fetchEventById(id: string): Promise<NEvent | undefined> {
|
||||||
return this.eventDataloader.load(id)
|
return this.eventDataloader.load(id)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user