refactor: follow functionality
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
96
src/renderer/src/providers/FollowListProvider.tsx
Normal file
96
src/renderer/src/providers/FollowListProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user