diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c24dd6f0..f3f0eabd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import FollowingListPage from './pages/secondary/FollowingListPage' import HashtagPage from './pages/secondary/HashtagPage' import NotePage from './pages/secondary/NotePage' import ProfilePage from './pages/secondary/ProfilePage' +import { FollowListProvider } from './providers/FollowListProvider' import { NostrProvider } from './providers/NostrProvider' import { NoteStatsProvider } from './providers/NoteStatsProvider' import { RelaySettingsProvider } from './providers/RelaySettingsProvider' @@ -25,14 +26,16 @@ export default function App(): JSX.Element {
- - - - - - - - + + + + + + + + + +
diff --git a/src/renderer/src/components/FollowButton/index.tsx b/src/renderer/src/components/FollowButton/index.tsx index 7a88cd18..b3db49d3 100644 --- a/src/renderer/src/components/FollowButton/index.tsx +++ b/src/renderer/src/components/FollowButton/index.tsx @@ -1,35 +1,24 @@ -import { TDraftEvent } from '@common/types' import { Button } from '@renderer/components/ui/button' -import { useFetchFollowings } from '@renderer/hooks' +import { useFollowList } from '@renderer/providers/FollowListProvider' 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 { pubkey: accountPubkey } = useNostr() + const { followListEvent, followings, isReady, follow, unfollow } = useFollowList() const [updating, setUpdating] = useState(false) const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey]) - if (!accountPubkey || pubkey === accountPubkey) return null + if (!accountPubkey || pubkey === accountPubkey || !isReady) return null - const follow = async (e: React.MouseEvent) => { + const handleFollow = 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() + await follow(pubkey) } catch (error) { console.error(error) } finally { @@ -37,22 +26,13 @@ export default function FollowButton({ pubkey }: { pubkey: string }) { } } - const unfollow = async (e: React.MouseEvent) => { + const handleUnfollow = 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() + await unfollow(pubkey) } catch (error) { console.error(error) } finally { @@ -64,13 +44,13 @@ export default function FollowButton({ pubkey }: { pubkey: string }) { ) : ( - ) diff --git a/src/renderer/src/hooks/useFetchFollowings.tsx b/src/renderer/src/hooks/useFetchFollowings.tsx index 6c44e493..2530ff25 100644 --- a/src/renderer/src/hooks/useFetchFollowings.tsx +++ b/src/renderer/src/hooks/useFetchFollowings.tsx @@ -1,6 +1,6 @@ import { tagNameEquals } from '@renderer/lib/tag' import client from '@renderer/services/client.service' -import { Event, kinds } from 'nostr-tools' +import { Event } from 'nostr-tools' import { useEffect, useState } from 'react' export function useFetchFollowings(pubkey?: string | null) { @@ -11,10 +11,7 @@ export function useFetchFollowings(pubkey?: string | null) { const init = async () => { if (!pubkey) return - const event = await client.fetchEventByFilter({ - authors: [pubkey], - kinds: [kinds.Contacts] - }) + const event = await client.fetchFollowListEvent(pubkey) if (!event) return setFollowListEvent(event) @@ -30,27 +27,5 @@ export function useFetchFollowings(pubkey?: string | null) { init() }, [pubkey]) - 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 } + return { followings, followListEvent } } diff --git a/src/renderer/src/pages/secondary/ProfilePage/index.tsx b/src/renderer/src/pages/secondary/ProfilePage/index.tsx index da764d48..b0603730 100644 --- a/src/renderer/src/pages/secondary/ProfilePage/index.tsx +++ b/src/renderer/src/pages/secondary/ProfilePage/index.tsx @@ -11,6 +11,7 @@ import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' import { toFollowingList } from '@renderer/lib/link' import { generateImageByPubkey } from '@renderer/lib/pubkey' import { SecondaryPageLink } from '@renderer/PageManager' +import { useFollowList } from '@renderer/providers/FollowListProvider' import { useNostr } from '@renderer/providers/NostrProvider' import { useMemo } from 'react' import PubkeyCopy from './PubkeyCopy' @@ -20,12 +21,14 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) { const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey) const relayList = useFetchRelayList(pubkey) const { pubkey: accountPubkey } = useNostr() + const { followings: selfFollowings } = useFollowList() const { followings } = useFetchFollowings(pubkey) const isFollowingYou = useMemo( () => !!accountPubkey && accountPubkey !== pubkey && followings.includes(accountPubkey), [followings, pubkey] ) const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey]) + const isSelf = accountPubkey === pubkey if (!pubkey) return null @@ -64,7 +67,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) { to={toFollowingList(pubkey)} className="mt-2 flex gap-1 hover:underline text-sm" > - {followings.length} + {isSelf ? selfFollowings.length : followings.length}
Following
diff --git a/src/renderer/src/providers/FollowListProvider.tsx b/src/renderer/src/providers/FollowListProvider.tsx new file mode 100644 index 00000000..e0edb74e --- /dev/null +++ b/src/renderer/src/providers/FollowListProvider.tsx @@ -0,0 +1,96 @@ +import { TDraftEvent } from '@common/types' +import { tagNameEquals } from '@renderer/lib/tag' +import client from '@renderer/services/client.service' +import dayjs from 'dayjs' +import { Event, kinds } from 'nostr-tools' +import { createContext, useContext, useEffect, useMemo, useState } from 'react' +import { useNostr } from './NostrProvider' + +type TFollowListContext = { + followListEvent: Event | undefined + followings: string[] + isReady: boolean + follow: (pubkey: string) => Promise + unfollow: (pubkey: string) => Promise +} + +const FollowListContext = createContext(undefined) + +export const useFollowList = () => { + const context = useContext(FollowListContext) + if (!context) { + throw new Error('useFollowList must be used within a FollowListProvider') + } + return context +} + +export function FollowListProvider({ children }: { children: React.ReactNode }) { + const { pubkey: accountPubkey, publish } = useNostr() + const [followListEvent, setFollowListEvent] = useState(undefined) + const [isReady, setIsReady] = useState(false) + const followings = useMemo( + () => + followListEvent?.tags + .filter(tagNameEquals('p')) + .map(([, pubkey]) => pubkey) + .filter(Boolean) + .reverse() ?? [], + [followListEvent] + ) + + useEffect(() => { + if (isReady || !accountPubkey) return + + const init = async () => { + const event = await client.fetchFollowListEvent(accountPubkey) + setFollowListEvent(event) + setIsReady(true) + } + + init() + }, [accountPubkey]) + + const follow = async (pubkey: string) => { + if (!isReady || !accountPubkey) return + + const newFollowListDraftEvent: TDraftEvent = { + kind: kinds.Contacts, + content: followListEvent?.content ?? '', + created_at: dayjs().unix(), + tags: (followListEvent?.tags ?? []).concat([['p', pubkey]]) + } + const newFollowListEvent = await publish(newFollowListDraftEvent) + client.updateFollowListCache(accountPubkey, newFollowListEvent) + setFollowListEvent(newFollowListEvent) + } + + const unfollow = async (pubkey: string) => { + if (!isReady || !accountPubkey || !followListEvent) return + + const newFollowListDraftEvent: TDraftEvent = { + kind: kinds.Contacts, + content: followListEvent.content ?? '', + created_at: dayjs().unix(), + tags: followListEvent.tags.filter( + ([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey + ) + } + const newFollowListEvent = await publish(newFollowListDraftEvent) + client.updateFollowListCache(accountPubkey, newFollowListEvent) + setFollowListEvent(newFollowListEvent) + } + + return ( + + {children} + + ) +} diff --git a/src/renderer/src/providers/NostrProvider.tsx b/src/renderer/src/providers/NostrProvider.tsx index 1786a488..34fb26a5 100644 --- a/src/renderer/src/providers/NostrProvider.tsx +++ b/src/renderer/src/providers/NostrProvider.tsx @@ -2,7 +2,7 @@ import { TDraftEvent } from '@common/types' import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList' import client from '@renderer/services/client.service' import dayjs from 'dayjs' -import { kinds } from 'nostr-tools' +import { Event, kinds } from 'nostr-tools' import { createContext, useContext, useEffect, useState } from 'react' type TNostrContext = { @@ -13,7 +13,7 @@ type TNostrContext = { /** * Default publish the event to current relays, user's write relays and additional relays */ - publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise + publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise signHttpAuth: (url: string, method: string) => Promise } @@ -66,6 +66,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { throw new Error('sign event failed') } await client.publishEvent(relayList.write.concat(additionalRelayUrls), event) + return event } const signHttpAuth = async (url: string, method: string) => { diff --git a/src/renderer/src/services/client.service.ts b/src/renderer/src/services/client.service.ts index d2ff1b22..f22c5b5c 100644 --- a/src/renderer/src/services/client.service.ts +++ b/src/renderer/src/services/client.service.ts @@ -51,6 +51,10 @@ class ClientService { cacheMap: new LRUCache>({ max: 10000 }) } ) + private followListCache = new LRUCache>({ + max: 10000, + fetchMethod: this._fetchFollowListEvent.bind(this) + }) constructor() { if (!ClientService.instance) { @@ -125,14 +129,6 @@ class ClientService { return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 })) } - deleteEventCacheByFilter(filter: Filter) { - try { - this.eventCache.delete(JSON.stringify({ ...filter, limit: 1 })) - } catch { - // ignore - } - } - async fetchEventById(id: string): Promise { return this.eventDataloader.load(id) } @@ -145,6 +141,14 @@ class ClientService { return this.relayListDataLoader.load(pubkey) } + async fetchFollowListEvent(pubkey: string) { + return this.followListCache.fetch(pubkey) + } + + updateFollowListCache(pubkey: string, event: NEvent) { + this.followListCache.set(pubkey, Promise.resolve(event)) + } + private async eventBatchLoadFn(ids: readonly string[]) { const events = await this.fetchEvents(this.relayUrls, { ids: ids as string[], @@ -255,6 +259,16 @@ class ClientService { }) } + private async _fetchFollowListEvent(pubkey: string) { + const relayList = await this.fetchRelayList(pubkey) + const followListEvents = await this.fetchEvents(relayList.write.concat(BIG_RELAY_URLS), { + authors: [pubkey], + kinds: [kinds.Contacts] + }) + + return followListEvents.sort((a, b) => b.created_at - a.created_at)[0] + } + private parseProfileFromEvent(event: NEvent): TProfile { try { const profileObj = JSON.parse(event.content)