refactor: follow functionality

This commit is contained in:
codytseng
2024-11-12 14:30:29 +08:00
parent 21e4e3badf
commit dcd94fdce3
7 changed files with 149 additions and 77 deletions

View File

@@ -9,6 +9,7 @@ import FollowingListPage from './pages/secondary/FollowingListPage'
import HashtagPage from './pages/secondary/HashtagPage' import HashtagPage from './pages/secondary/HashtagPage'
import NotePage from './pages/secondary/NotePage' import NotePage from './pages/secondary/NotePage'
import ProfilePage from './pages/secondary/ProfilePage' import ProfilePage from './pages/secondary/ProfilePage'
import { FollowListProvider } from './providers/FollowListProvider'
import { NostrProvider } from './providers/NostrProvider' import { NostrProvider } from './providers/NostrProvider'
import { NoteStatsProvider } from './providers/NoteStatsProvider' import { NoteStatsProvider } from './providers/NoteStatsProvider'
import { RelaySettingsProvider } from './providers/RelaySettingsProvider' import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
@@ -25,14 +26,16 @@ export default function App(): JSX.Element {
<div className="h-screen"> <div className="h-screen">
<ThemeProvider> <ThemeProvider>
<NostrProvider> <NostrProvider>
<RelaySettingsProvider> <FollowListProvider>
<NoteStatsProvider> <RelaySettingsProvider>
<PageManager routes={routes}> <NoteStatsProvider>
<NoteListPage /> <PageManager routes={routes}>
</PageManager> <NoteListPage />
<Toaster /> </PageManager>
</NoteStatsProvider> <Toaster />
</RelaySettingsProvider> </NoteStatsProvider>
</RelaySettingsProvider>
</FollowListProvider>
</NostrProvider> </NostrProvider>
</ThemeProvider> </ThemeProvider>
</div> </div>

View File

@@ -1,35 +1,24 @@
import { TDraftEvent } from '@common/types'
import { Button } from '@renderer/components/ui/button' 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 { useNostr } from '@renderer/providers/NostrProvider'
import dayjs from 'dayjs'
import { Loader } from 'lucide-react' import { Loader } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
export default function FollowButton({ pubkey }: { pubkey: string }) { export default function FollowButton({ pubkey }: { pubkey: string }) {
const { pubkey: accountPubkey, publish } = useNostr() const { pubkey: accountPubkey } = useNostr()
const { followings, followListEvent, refresh } = useFetchFollowings(accountPubkey) const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey]) 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() e.stopPropagation()
if (isFollowing) return if (isFollowing) return
setUpdating(true) 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 { try {
await publish(newFollowListEvent) await follow(pubkey)
await refresh()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } 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() e.stopPropagation()
if (!isFollowing || !followListEvent) return if (!isFollowing || !followListEvent) return
setUpdating(true) 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 { try {
await publish(newFollowListEvent) await unfollow(pubkey)
await refresh()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
@@ -64,13 +44,13 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
<Button <Button
className="w-20 min-w-20 rounded-full" className="w-20 min-w-20 rounded-full"
variant="secondary" variant="secondary"
onClick={unfollow} onClick={handleUnfollow}
disabled={updating} disabled={updating}
> >
{updating ? <Loader className="animate-spin" /> : 'Unfollow'} {updating ? <Loader className="animate-spin" /> : 'Unfollow'}
</Button> </Button>
) : ( ) : (
<Button className="w-20 min-w-20 rounded-full" onClick={follow} disabled={updating}> <Button className="w-20 min-w-20 rounded-full" onClick={handleFollow} disabled={updating}>
{updating ? <Loader className="animate-spin" /> : 'Follow'} {updating ? <Loader className="animate-spin" /> : 'Follow'}
</Button> </Button>
) )

View File

@@ -1,6 +1,6 @@
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 { Event, kinds } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export function useFetchFollowings(pubkey?: string | null) { export function useFetchFollowings(pubkey?: string | null) {
@@ -11,10 +11,7 @@ export function useFetchFollowings(pubkey?: string | null) {
const init = async () => { const init = async () => {
if (!pubkey) return if (!pubkey) return
const event = await client.fetchEventByFilter({ const event = await client.fetchFollowListEvent(pubkey)
authors: [pubkey],
kinds: [kinds.Contacts]
})
if (!event) return if (!event) return
setFollowListEvent(event) setFollowListEvent(event)
@@ -30,27 +27,5 @@ export function useFetchFollowings(pubkey?: string | null) {
init() init()
}, [pubkey]) }, [pubkey])
const refresh = async () => { return { followings, followListEvent }
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

@@ -11,6 +11,7 @@ import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { toFollowingList } from '@renderer/lib/link' import { toFollowingList } from '@renderer/lib/link'
import { generateImageByPubkey } from '@renderer/lib/pubkey' import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { SecondaryPageLink } from '@renderer/PageManager' import { SecondaryPageLink } from '@renderer/PageManager'
import { useFollowList } from '@renderer/providers/FollowListProvider'
import { useNostr } from '@renderer/providers/NostrProvider' import { useNostr } from '@renderer/providers/NostrProvider'
import { useMemo } from 'react' import { useMemo } from 'react'
import PubkeyCopy from './PubkeyCopy' import PubkeyCopy from './PubkeyCopy'
@@ -20,12 +21,14 @@ 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 { pubkey: accountPubkey } = useNostr() const { pubkey: accountPubkey } = useNostr()
const { followings: selfFollowings } = useFollowList()
const { followings } = useFetchFollowings(pubkey) const { followings } = useFetchFollowings(pubkey)
const isFollowingYou = useMemo( const isFollowingYou = useMemo(
() => !!accountPubkey && accountPubkey !== pubkey && followings.includes(accountPubkey), () => !!accountPubkey && accountPubkey !== pubkey && followings.includes(accountPubkey),
[followings, pubkey] [followings, pubkey]
) )
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey]) const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
const isSelf = accountPubkey === pubkey
if (!pubkey) return null if (!pubkey) return null
@@ -64,7 +67,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
to={toFollowingList(pubkey)} to={toFollowingList(pubkey)}
className="mt-2 flex gap-1 hover:underline text-sm" className="mt-2 flex gap-1 hover:underline text-sm"
> >
{followings.length} {isSelf ? selfFollowings.length : followings.length}
<div className="text-muted-foreground">Following</div> <div className="text-muted-foreground">Following</div>
</SecondaryPageLink> </SecondaryPageLink>
</div> </div>

View File

@@ -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<void>
unfollow: (pubkey: string) => Promise<void>
}
const FollowListContext = createContext<TFollowListContext | undefined>(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<Event | undefined>(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 (
<FollowListContext.Provider
value={{
followListEvent,
followings,
isReady,
follow,
unfollow
}}
>
{children}
</FollowListContext.Provider>
)
}

View File

@@ -2,7 +2,7 @@ import { TDraftEvent } from '@common/types'
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList' import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
import client from '@renderer/services/client.service' import client from '@renderer/services/client.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
type TNostrContext = { type TNostrContext = {
@@ -13,7 +13,7 @@ type TNostrContext = {
/** /**
* Default publish the event to current relays, user's write relays and additional relays * Default publish the event to current relays, user's write relays and additional relays
*/ */
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<void> publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
signHttpAuth: (url: string, method: string) => Promise<string> signHttpAuth: (url: string, method: string) => Promise<string>
} }
@@ -66,6 +66,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
throw new Error('sign event failed') throw new Error('sign event failed')
} }
await client.publishEvent(relayList.write.concat(additionalRelayUrls), event) await client.publishEvent(relayList.write.concat(additionalRelayUrls), event)
return event
} }
const signHttpAuth = async (url: string, method: string) => { const signHttpAuth = async (url: string, method: string) => {

View File

@@ -51,6 +51,10 @@ class ClientService {
cacheMap: new LRUCache<string, Promise<TRelayList>>({ max: 10000 }) cacheMap: new LRUCache<string, Promise<TRelayList>>({ max: 10000 })
} }
) )
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000,
fetchMethod: this._fetchFollowListEvent.bind(this)
})
constructor() { constructor() {
if (!ClientService.instance) { if (!ClientService.instance) {
@@ -125,14 +129,6 @@ class ClientService {
return this.eventCache.fetch(JSON.stringify({ ...filter, limit: 1 })) 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<NEvent | undefined> { async fetchEventById(id: string): Promise<NEvent | undefined> {
return this.eventDataloader.load(id) return this.eventDataloader.load(id)
} }
@@ -145,6 +141,14 @@ class ClientService {
return this.relayListDataLoader.load(pubkey) 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[]) { private async eventBatchLoadFn(ids: readonly string[]) {
const events = await this.fetchEvents(this.relayUrls, { const events = await this.fetchEvents(this.relayUrls, {
ids: ids as string[], 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 { private parseProfileFromEvent(event: NEvent): TProfile {
try { try {
const profileObj = JSON.parse(event.content) const profileObj = JSON.parse(event.content)