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,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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user