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 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 {
<div className="h-screen">
<ThemeProvider>
<NostrProvider>
<RelaySettingsProvider>
<NoteStatsProvider>
<PageManager routes={routes}>
<NoteListPage />
</PageManager>
<Toaster />
</NoteStatsProvider>
</RelaySettingsProvider>
<FollowListProvider>
<RelaySettingsProvider>
<NoteStatsProvider>
<PageManager routes={routes}>
<NoteListPage />
</PageManager>
<Toaster />
</NoteStatsProvider>
</RelaySettingsProvider>
</FollowListProvider>
</NostrProvider>
</ThemeProvider>
</div>

View File

@@ -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 }) {
<Button
className="w-20 min-w-20 rounded-full"
variant="secondary"
onClick={unfollow}
onClick={handleUnfollow}
disabled={updating}
>
{updating ? <Loader className="animate-spin" /> : 'Unfollow'}
</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'}
</Button>
)

View File

@@ -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 }
}

View File

@@ -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}
<div className="text-muted-foreground">Following</div>
</SecondaryPageLink>
</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 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<void>
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
signHttpAuth: (url: string, method: string) => Promise<string>
}
@@ -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) => {

View File

@@ -51,6 +51,10 @@ class ClientService {
cacheMap: new LRUCache<string, Promise<TRelayList>>({ max: 10000 })
}
)
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
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<NEvent | undefined> {
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)