From 3f9042b3be343ebb75dc6fed94719e2504db04c6 Mon Sep 17 00:00:00 2001 From: codytseng Date: Mon, 11 Nov 2024 23:24:35 +0800 Subject: [PATCH] feat: follow button --- .../src/components/FollowButton/index.tsx | 77 +++++++++++++++++++ .../src/components/ProfileBanner/index.tsx | 32 ++++++++ .../src/components/ProfileCard/index.tsx | 18 +++-- src/renderer/src/hooks/useFetchFollowings.tsx | 39 ++++++++-- .../secondary/FollowingListPage/index.tsx | 8 +- .../src/pages/secondary/ProfilePage/index.tsx | 56 +++++--------- src/renderer/src/services/client.service.ts | 4 + 7 files changed, 179 insertions(+), 55 deletions(-) create mode 100644 src/renderer/src/components/FollowButton/index.tsx create mode 100644 src/renderer/src/components/ProfileBanner/index.tsx diff --git a/src/renderer/src/components/FollowButton/index.tsx b/src/renderer/src/components/FollowButton/index.tsx new file mode 100644 index 00000000..7a88cd18 --- /dev/null +++ b/src/renderer/src/components/FollowButton/index.tsx @@ -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 ? ( + + ) : ( + + ) +} diff --git a/src/renderer/src/components/ProfileBanner/index.tsx b/src/renderer/src/components/ProfileBanner/index.tsx new file mode 100644 index 00000000..d30d5ee7 --- /dev/null +++ b/src/renderer/src/components/ProfileBanner/index.tsx @@ -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 ( + {`${pubkey} setBannerUrl(defaultBanner)} + /> + ) +} diff --git a/src/renderer/src/components/ProfileCard/index.tsx b/src/renderer/src/components/ProfileCard/index.tsx index 3abf14a7..2be32d26 100644 --- a/src/renderer/src/components/ProfileCard/index.tsx +++ b/src/renderer/src/components/ProfileCard/index.tsx @@ -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 (
-
+
- + - {pubkey} + {pubkey} -
-
{username}
- {nip05 && } -
+ +
+
+
{username}
+ {nip05 && }
{about && (
(null) const [followings, setFollowings] = useState([]) 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 } } diff --git a/src/renderer/src/pages/secondary/FollowingListPage/index.tsx b/src/renderer/src/pages/secondary/FollowingListPage/index.tsx index cb096f9e..5c9c33bb 100644 --- a/src/renderer/src/pages/secondary/FollowingListPage/index.tsx +++ b/src/renderer/src/pages/secondary/FollowingListPage/index.tsx @@ -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([]) const observer = useRef(null) const bottomRef = useRef(null) @@ -47,7 +48,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
{visibleFollowings.map((pubkey, index) => ( - + ))} {followings.length > visibleFollowings.length &&
}
@@ -64,8 +65,9 @@ function FollowingItem({ pubkey }: { pubkey: string }) {
-
{about}
+
{about}
+
) } diff --git a/src/renderer/src/pages/secondary/ProfilePage/index.tsx b/src/renderer/src/pages/secondary/ProfilePage/index.tsx index 3d8438b2..e5143ab5 100644 --- a/src/renderer/src/pages/secondary/ProfilePage/index.tsx +++ b/src/renderer/src/pages/secondary/ProfilePage/index.tsx @@ -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 ( -
+
@@ -45,7 +52,15 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
-
+
+ {isFollowingYou && ( +
+ Follows you +
+ )} + +
+
{username}
{nip05 && }
) } - -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 ( - {`${pubkey} setBannerUrl(defaultBanner)} - /> - ) -} diff --git a/src/renderer/src/services/client.service.ts b/src/renderer/src/services/client.service.ts index 73e198c8..d6840e88 100644 --- a/src/renderer/src/services/client.service.ts +++ b/src/renderer/src/services/client.service.ts @@ -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 { return this.eventDataloader.load(id) }