feat: follow button

This commit is contained in:
codytseng
2024-11-11 23:24:35 +08:00
parent d5b941535f
commit 3f9042b3be
7 changed files with 179 additions and 55 deletions

View 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>
)
}

View 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)}
/>
)
}

View File

@@ -2,26 +2,28 @@ import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/ava
import { useFetchProfile } from '@renderer/hooks'
import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { useMemo } from 'react'
import FollowButton from '../FollowButton'
import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout'
export default function ProfileCard({ pubkey }: { pubkey: string }) {
const { avatar = '', username, nip05, about } = useFetchProfile(pubkey)
const defaultAvatar = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
return (
<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">
<AvatarImage src={avatar} />
<AvatarImage className="object-cover object-center" src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} alt={pubkey} />
<img src={defaultImage} alt={pubkey} />
</AvatarFallback>
</Avatar>
<div className="flex-1 w-0">
<div className="text-lg font-semibold truncate">{username}</div>
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
</div>
<FollowButton pubkey={pubkey} />
</div>
<div>
<div className="text-lg font-semibold truncate">{username}</div>
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
</div>
{about && (
<div

View File

@@ -1,24 +1,25 @@
import { tagNameEquals } from '@renderer/lib/tag'
import client from '@renderer/services/client.service'
import { kinds } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
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[]>([])
useEffect(() => {
const init = async () => {
if (!pubkey) return
const followListEvent = await client.fetchEventByFilter({
const event = await client.fetchEventByFilter({
authors: [pubkey],
kinds: [kinds.Contacts],
limit: 1
kinds: [kinds.Contacts]
})
if (!followListEvent) return
if (!event) return
setFollowListEvent(event)
setFollowings(
followListEvent.tags
event.tags
.filter(tagNameEquals('p'))
.map(([, pubkey]) => pubkey)
.filter(Boolean)
@@ -29,5 +30,27 @@ export function useFetchFollowings(pubkey?: string) {
init()
}, [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 }
}

View File

@@ -1,3 +1,4 @@
import FollowButton from '@renderer/components/FollowButton'
import Nip05 from '@renderer/components/Nip05'
import UserAvatar from '@renderer/components/UserAvatar'
import Username from '@renderer/components/Username'
@@ -7,7 +8,7 @@ import { useEffect, useRef, useState } from 'react'
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
const { username } = useFetchProfile(pubkey)
const followings = useFetchFollowings(pubkey)
const { followings } = useFetchFollowings(pubkey)
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
const observer = useRef<IntersectionObserver | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
@@ -47,7 +48,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
<SecondaryPageLayout titlebarContent={username ? `${username}'s following` : 'following'}>
<div className="space-y-2">
{visibleFollowings.map((pubkey, index) => (
<FollowingItem key={index} pubkey={pubkey} />
<FollowingItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
</div>
@@ -64,8 +65,9 @@ function FollowingItem({ pubkey }: { pubkey: string }) {
<div className="w-full overflow-hidden">
<Username userId={pubkey} className="font-semibold truncate" />
<Nip05 nip05={nip05} pubkey={pubkey} />
<div className="truncate text-muted-foreground">{about}</div>
<div className="truncate text-muted-foreground text-sm">{about}</div>
</div>
<FollowButton pubkey={pubkey} />
</div>
)
}

View File

@@ -1,6 +1,8 @@
import FollowButton from '@renderer/components/FollowButton'
import Nip05 from '@renderer/components/Nip05'
import NoteList from '@renderer/components/NoteList'
import ProfileAbout from '@renderer/components/ProfileAbout'
import ProfileBanner from '@renderer/components/ProfileBanner'
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import { Separator } from '@renderer/components/ui/separator'
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
@@ -9,15 +11,21 @@ import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { toFollowingList } from '@renderer/lib/link'
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
import { SecondaryPageLink } from '@renderer/PageManager'
import { useNostr } from '@renderer/providers/NostrProvider'
import { Copy } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey)
const relayList = useFetchRelayList(pubkey)
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 defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
@@ -31,10 +39,9 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
return (
<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
banner={banner}
defaultBanner={defaultImage}
pubkey={pubkey}
className="w-full h-full object-cover rounded-lg"
/>
@@ -45,7 +52,15 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
</AvatarFallback>
</Avatar>
</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>
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
<div
@@ -81,34 +96,3 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
</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)}
/>
)
}

View File

@@ -125,6 +125,10 @@ class ClientService {
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> {
return this.eventDataloader.load(id)
}