feat: zap (#107)
This commit is contained in:
@@ -7,7 +7,6 @@ import relayInfoService from '@/services/relay-info.service'
|
||||
import { TFeedType } from '@/types'
|
||||
import { Filter } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useFollowList } from './FollowListProvider'
|
||||
import { useNostr } from './NostrProvider'
|
||||
import { useRelaySets } from './RelaySetsProvider'
|
||||
|
||||
@@ -36,8 +35,7 @@ export const useFeed = () => {
|
||||
|
||||
export function FeedProvider({ children }: { children: React.ReactNode }) {
|
||||
const isFirstRenderRef = useRef(true)
|
||||
const { pubkey, getRelayList } = useNostr()
|
||||
const { getFollowings } = useFollowList()
|
||||
const { pubkey } = useNostr()
|
||||
const { relaySets } = useRelaySets()
|
||||
const feedTypeRef = useRef<TFeedType>(storage.getFeedType())
|
||||
const [feedType, setFeedType] = useState<TFeedType>(feedTypeRef.current)
|
||||
@@ -120,8 +118,8 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
|
||||
setFeedType(feedType)
|
||||
setActiveRelaySetId(null)
|
||||
const [relayList, followings] = await Promise.all([
|
||||
getRelayList(options.pubkey),
|
||||
getFollowings(options.pubkey)
|
||||
client.fetchRelayList(options.pubkey),
|
||||
client.fetchFollowings(options.pubkey, true)
|
||||
])
|
||||
setRelayUrls(relayList.read.concat(BIG_RELAY_URLS).slice(0, 4))
|
||||
setFilter({
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { createFollowListDraftEvent } from '@/lib/draft-event'
|
||||
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import indexedDb from '@/services/indexed-db.service'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { createContext, useContext, useMemo } from 'react'
|
||||
import { useNostr } from './NostrProvider'
|
||||
|
||||
type TFollowListContext = {
|
||||
followListEvent: Event | undefined
|
||||
followings: string[]
|
||||
isFetching: boolean
|
||||
getFollowings: (pubkey: string) => Promise<string[]>
|
||||
follow: (pubkey: string) => Promise<void>
|
||||
unfollow: (pubkey: string) => Promise<void>
|
||||
}
|
||||
@@ -26,81 +21,42 @@ export const useFollowList = () => {
|
||||
}
|
||||
|
||||
export function FollowListProvider({ children }: { children: React.ReactNode }) {
|
||||
const { pubkey: accountPubkey, publish } = useNostr()
|
||||
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr()
|
||||
const followings = useMemo(
|
||||
() => (followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []),
|
||||
[followListEvent]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const follow = async (pubkey: string) => {
|
||||
if (!accountPubkey) return
|
||||
|
||||
const init = async () => {
|
||||
setIsFetching(true)
|
||||
setFollowListEvent(undefined)
|
||||
const storedFollowListEvent = await indexedDb.getReplaceableEvent(
|
||||
accountPubkey,
|
||||
kinds.Contacts
|
||||
)
|
||||
if (storedFollowListEvent) {
|
||||
setFollowListEvent(storedFollowListEvent)
|
||||
}
|
||||
const event = await client.fetchFollowListEvent(accountPubkey, true)
|
||||
if (event) {
|
||||
await updateFollowListEvent(event)
|
||||
}
|
||||
setIsFetching(false)
|
||||
}
|
||||
|
||||
init()
|
||||
}, [accountPubkey])
|
||||
|
||||
const updateFollowListEvent = async (event: Event) => {
|
||||
const newEvent = await indexedDb.putReplaceableEvent(event)
|
||||
setFollowListEvent(newEvent)
|
||||
}
|
||||
|
||||
const follow = async (pubkey: string) => {
|
||||
if (isFetching || !accountPubkey) return
|
||||
|
||||
const followListEvent = await client.fetchFollowListEvent(accountPubkey)
|
||||
const newFollowListDraftEvent = createFollowListDraftEvent(
|
||||
(followListEvent?.tags ?? []).concat([['p', pubkey]]),
|
||||
followListEvent?.content
|
||||
)
|
||||
const newFollowListEvent = await publish(newFollowListDraftEvent)
|
||||
client.updateFollowListCache(accountPubkey, newFollowListEvent)
|
||||
await updateFollowListEvent(newFollowListEvent)
|
||||
}
|
||||
|
||||
const unfollow = async (pubkey: string) => {
|
||||
if (isFetching || !accountPubkey || !followListEvent) return
|
||||
if (!accountPubkey) return
|
||||
|
||||
const followListEvent = await client.fetchFollowListEvent(accountPubkey)
|
||||
if (!followListEvent) return
|
||||
|
||||
const newFollowListDraftEvent = createFollowListDraftEvent(
|
||||
followListEvent.tags.filter(([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey),
|
||||
followListEvent.content
|
||||
)
|
||||
const newFollowListEvent = await publish(newFollowListDraftEvent)
|
||||
client.updateFollowListCache(accountPubkey, newFollowListEvent)
|
||||
await updateFollowListEvent(newFollowListEvent)
|
||||
}
|
||||
|
||||
const getFollowings = async (pubkey: string) => {
|
||||
const followListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts)
|
||||
if (followListEvent) {
|
||||
return extractPubkeysFromEventTags(followListEvent.tags)
|
||||
}
|
||||
return await client.fetchFollowings(pubkey)
|
||||
}
|
||||
|
||||
return (
|
||||
<FollowListContext.Provider
|
||||
value={{
|
||||
followListEvent,
|
||||
followings,
|
||||
isFetching,
|
||||
getFollowings,
|
||||
follow,
|
||||
unfollow
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { createMuteListDraftEvent } from '@/lib/draft-event'
|
||||
import { getLatestEvent } from '@/lib/event'
|
||||
import { extractPubkeysFromEventTags, isSameTag } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import indexedDb from '@/services/indexed-db.service'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useNostr } from './NostrProvider'
|
||||
@@ -26,66 +22,42 @@ export const useMuteList = () => {
|
||||
}
|
||||
|
||||
export function MuteListProvider({ children }: { children: React.ReactNode }) {
|
||||
const { pubkey: accountPubkey, publish, relayList, nip04Decrypt, nip04Encrypt } = useNostr()
|
||||
const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined)
|
||||
const {
|
||||
pubkey: accountPubkey,
|
||||
muteListEvent,
|
||||
publish,
|
||||
updateMuteListEvent,
|
||||
nip04Decrypt,
|
||||
nip04Encrypt
|
||||
} = useNostr()
|
||||
const [tags, setTags] = useState<string[][]>([])
|
||||
const mutePubkeys = useMemo(() => extractPubkeysFromEventTags(tags), [tags])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountPubkey) return
|
||||
const updateMuteTags = async () => {
|
||||
if (!muteListEvent) return
|
||||
|
||||
const init = async () => {
|
||||
setMuteListEvent(undefined)
|
||||
const storedMuteListEvent = await indexedDb.getReplaceableEvent(accountPubkey, kinds.Mutelist)
|
||||
if (storedMuteListEvent) {
|
||||
setMuteListEvent(storedMuteListEvent)
|
||||
const tags = await extractMuteTags(storedMuteListEvent)
|
||||
setTags(tags)
|
||||
}
|
||||
const events = await client.fetchEvents(relayList?.write ?? BIG_RELAY_URLS, {
|
||||
kinds: [kinds.Mutelist],
|
||||
authors: [accountPubkey]
|
||||
})
|
||||
const muteEvent = getLatestEvent(events) as Event | undefined
|
||||
if (muteEvent) {
|
||||
const newMuteEvent = await indexedDb.putReplaceableEvent(muteEvent)
|
||||
setMuteListEvent(newMuteEvent)
|
||||
const tags = await extractMuteTags(newMuteEvent)
|
||||
setTags(tags)
|
||||
}
|
||||
}
|
||||
const tags = [...muteListEvent.tags]
|
||||
if (muteListEvent.content) {
|
||||
const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
|
||||
|
||||
init()
|
||||
}, [accountPubkey])
|
||||
|
||||
const extractMuteTags = async (muteListEvent: Event) => {
|
||||
const tags = [...muteListEvent.tags]
|
||||
if (muteListEvent.content) {
|
||||
const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
|
||||
|
||||
if (storedDecryptedTags) {
|
||||
tags.push(...storedDecryptedTags)
|
||||
} else {
|
||||
try {
|
||||
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
|
||||
const contentTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
|
||||
await indexedDb.putMuteDecryptedTags(muteListEvent.id, contentTags)
|
||||
tags.push(...contentTags.filter((tag) => tags.every((t) => !isSameTag(t, tag))))
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt mute list content', error)
|
||||
if (storedDecryptedTags) {
|
||||
tags.push(...storedDecryptedTags)
|
||||
} else {
|
||||
try {
|
||||
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
|
||||
const contentTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
|
||||
await indexedDb.putMuteDecryptedTags(muteListEvent.id, contentTags)
|
||||
tags.push(...contentTags.filter((tag) => tags.every((t) => !isSameTag(t, tag))))
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt mute list content', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
setTags(tags)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
const update = async (event: Event, tags: string[][]) => {
|
||||
const isNew = await indexedDb.putReplaceableEvent(event)
|
||||
if (!isNew) return
|
||||
await indexedDb.putMuteDecryptedTags(event.id, tags)
|
||||
setMuteListEvent(event)
|
||||
setTags(tags)
|
||||
}
|
||||
updateMuteTags()
|
||||
}, [muteListEvent])
|
||||
|
||||
const mutePubkey = async (pubkey: string) => {
|
||||
if (!accountPubkey) return
|
||||
@@ -94,7 +66,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
|
||||
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newTags))
|
||||
const newMuteListDraftEvent = createMuteListDraftEvent(muteListEvent?.tags ?? [], cipherText)
|
||||
const newMuteListEvent = await publish(newMuteListDraftEvent)
|
||||
await update(newMuteListEvent, newTags)
|
||||
await updateMuteListEvent(newMuteListEvent, newTags)
|
||||
}
|
||||
|
||||
const unmutePubkey = async (pubkey: string) => {
|
||||
@@ -107,7 +79,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
|
||||
cipherText
|
||||
)
|
||||
const newMuteListEvent = await publish(newMuteListDraftEvent)
|
||||
await update(newMuteListEvent, newTags)
|
||||
await updateMuteListEvent(newMuteListEvent, newTags)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import LoginDialog from '@/components/LoginDialog'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { useToast } from '@/hooks'
|
||||
import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/lib/event'
|
||||
import { formatPubkey } from '@/lib/pubkey'
|
||||
import {
|
||||
getLatestEvent,
|
||||
getProfileFromProfileEvent,
|
||||
getRelayListFromRelayListEvent
|
||||
} from '@/lib/event'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import indexedDb from '@/services/indexed-db.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds, VerifiedEvent } from 'nostr-tools'
|
||||
@@ -22,6 +25,8 @@ type TNostrContext = {
|
||||
profile: TProfile | null
|
||||
profileEvent: Event | null
|
||||
relayList: TRelayList | null
|
||||
followListEvent?: Event
|
||||
muteListEvent?: Event
|
||||
account: TAccountPointer | null
|
||||
accounts: TAccountPointer[]
|
||||
nsec: string | null
|
||||
@@ -45,9 +50,10 @@ type TNostrContext = {
|
||||
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
||||
startLogin: () => void
|
||||
checkLogin: <T>(cb?: () => T) => Promise<T | void>
|
||||
getRelayList: (pubkey: string) => Promise<TRelayList>
|
||||
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
|
||||
updateProfileEvent: (profileEvent: Event) => Promise<void>
|
||||
updateFollowListEvent: (followListEvent: Event) => Promise<void>
|
||||
updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise<void>
|
||||
}
|
||||
|
||||
const NostrContext = createContext<TNostrContext | undefined>(undefined)
|
||||
@@ -71,6 +77,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||
const [profileEvent, setProfileEvent] = useState<Event | null>(null)
|
||||
const [relayList, setRelayList] = useState<TRelayList | null>(null)
|
||||
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
|
||||
const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -122,10 +130,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
} else {
|
||||
setNcryptsec(null)
|
||||
}
|
||||
const [storedRelayListEvent, storedProfileEvent] = await Promise.all([
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata)
|
||||
])
|
||||
const [storedRelayListEvent, storedProfileEvent, storedFollowListEvent, storedMuteListEvent] =
|
||||
await Promise.all([
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts),
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist)
|
||||
])
|
||||
if (storedRelayListEvent) {
|
||||
setRelayList(
|
||||
storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null
|
||||
@@ -135,35 +146,47 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
setProfileEvent(storedProfileEvent)
|
||||
setProfile(getProfileFromProfileEvent(storedProfileEvent))
|
||||
}
|
||||
if (storedFollowListEvent) {
|
||||
setFollowListEvent(storedFollowListEvent)
|
||||
}
|
||||
if (storedMuteListEvent) {
|
||||
setMuteListEvent(storedMuteListEvent)
|
||||
}
|
||||
|
||||
client.fetchRelayListEvent(account.pubkey).then(async (relayListEvent) => {
|
||||
if (!relayListEvent) {
|
||||
if (storedRelayListEvent) return
|
||||
|
||||
setRelayList({ write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] })
|
||||
return
|
||||
}
|
||||
const event = await indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList)
|
||||
if (event) {
|
||||
setRelayList(getRelayListFromRelayListEvent(event))
|
||||
}
|
||||
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
|
||||
kinds: [kinds.RelayList],
|
||||
authors: [account.pubkey]
|
||||
})
|
||||
client.fetchProfileEvent(account.pubkey).then(async (profileEvent) => {
|
||||
if (!profileEvent) {
|
||||
if (storedProfileEvent) return
|
||||
const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
|
||||
const relayList = getRelayListFromRelayListEvent(relayListEvent)
|
||||
if (relayListEvent) {
|
||||
client.updateRelayListCache(relayListEvent)
|
||||
await indexedDb.putReplaceableEvent(relayListEvent)
|
||||
}
|
||||
setRelayList(relayList)
|
||||
|
||||
setProfile({
|
||||
pubkey: account.pubkey,
|
||||
username: formatPubkey(account.pubkey)
|
||||
})
|
||||
return
|
||||
}
|
||||
const event = await indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata)
|
||||
if (event) {
|
||||
setProfileEvent(event)
|
||||
setProfile(getProfileFromProfileEvent(event))
|
||||
}
|
||||
const events = await client.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 4), {
|
||||
kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist],
|
||||
authors: [account.pubkey]
|
||||
})
|
||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata)
|
||||
const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts)
|
||||
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
|
||||
if (profileEvent) {
|
||||
setProfileEvent(profileEvent)
|
||||
setProfile(getProfileFromProfileEvent(profileEvent))
|
||||
await indexedDb.putReplaceableEvent(profileEvent)
|
||||
}
|
||||
if (followListEvent) {
|
||||
setFollowListEvent(followListEvent)
|
||||
await indexedDb.putReplaceableEvent(followListEvent)
|
||||
}
|
||||
if (muteListEvent) {
|
||||
setMuteListEvent(muteListEvent)
|
||||
await indexedDb.putReplaceableEvent(muteListEvent)
|
||||
}
|
||||
|
||||
client.initUserIndexFromFollowings(account.pubkey, controller.signal)
|
||||
return controller
|
||||
}
|
||||
@@ -396,14 +419,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
return setOpenLoginDialog(true)
|
||||
}
|
||||
|
||||
const getRelayList = async (pubkey: string) => {
|
||||
const storedRelayListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
|
||||
if (storedRelayListEvent) {
|
||||
return getRelayListFromRelayListEvent(storedRelayListEvent)
|
||||
}
|
||||
return await client.fetchRelayList(pubkey)
|
||||
}
|
||||
|
||||
const updateRelayListEvent = async (relayListEvent: Event) => {
|
||||
const newRelayList = await indexedDb.putReplaceableEvent(relayListEvent)
|
||||
setRelayList(getRelayListFromRelayListEvent(newRelayList))
|
||||
@@ -413,7 +428,22 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
const newProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
|
||||
setProfileEvent(newProfileEvent)
|
||||
setProfile(getProfileFromProfileEvent(newProfileEvent))
|
||||
client.updateProfileCache(newProfileEvent)
|
||||
}
|
||||
|
||||
const updateFollowListEvent = async (followListEvent: Event) => {
|
||||
const newFollowListEvent = await indexedDb.putReplaceableEvent(followListEvent)
|
||||
if (newFollowListEvent.id !== followListEvent.id) return
|
||||
|
||||
setFollowListEvent(newFollowListEvent)
|
||||
client.updateFollowListCache(newFollowListEvent)
|
||||
}
|
||||
|
||||
const updateMuteListEvent = async (muteListEvent: Event, tags: string[][]) => {
|
||||
const newMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent)
|
||||
if (newMuteListEvent.id !== muteListEvent.id) return
|
||||
|
||||
await indexedDb.putMuteDecryptedTags(muteListEvent.id, tags)
|
||||
setMuteListEvent(muteListEvent)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -423,6 +453,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
profile,
|
||||
profileEvent,
|
||||
relayList,
|
||||
followListEvent,
|
||||
muteListEvent,
|
||||
account,
|
||||
accounts: storage
|
||||
.getAccounts()
|
||||
@@ -442,9 +474,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
startLogin: () => setOpenLoginDialog(true),
|
||||
checkLogin,
|
||||
signEvent,
|
||||
getRelayList,
|
||||
updateRelayListEvent,
|
||||
updateProfileEvent
|
||||
updateProfileEvent,
|
||||
updateFollowListEvent,
|
||||
updateMuteListEvent
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { extractZapInfoFromReceipt } from '@/lib/event'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, Filter, kinds } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useNostr } from './NostrProvider'
|
||||
|
||||
export type TNoteStats = {
|
||||
likeCount: number
|
||||
repostCount: number
|
||||
likes: Set<string>
|
||||
reposts: Set<string>
|
||||
zaps: { pr: string; pubkey: string; amount: number; comment?: string }[]
|
||||
replyCount: number
|
||||
hasLiked: boolean
|
||||
hasReposted: boolean
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
type TNoteStatsContext = {
|
||||
noteStatsMap: Map<string, Partial<TNoteStats>>
|
||||
updateNoteReplyCount: (noteId: string, replyCount: number) => void
|
||||
markNoteAsLiked: (noteId: string) => void
|
||||
markNoteAsReposted: (noteId: string) => void
|
||||
fetchNoteLikeCount: (event: Event) => Promise<number>
|
||||
fetchNoteRepostCount: (event: Event) => Promise<number>
|
||||
fetchNoteLikedStatus: (event: Event) => Promise<boolean>
|
||||
fetchNoteRepostedStatus: (event: Event) => Promise<boolean>
|
||||
addZap: (eventId: string, pr: string, amount: number, comment?: string) => void
|
||||
updateNoteStatsByEvents: (events: Event[]) => void
|
||||
fetchNoteStats: (event: Event) => Promise<Partial<TNoteStats> | undefined>
|
||||
}
|
||||
|
||||
const NoteStatsContext = createContext<TNoteStatsContext | undefined>(undefined)
|
||||
@@ -38,145 +37,183 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
|
||||
const { pubkey } = useNostr()
|
||||
|
||||
useEffect(() => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map()
|
||||
for (const [noteId, stats] of prev) {
|
||||
newMap.set(noteId, { ...stats, hasLiked: undefined, hasReposted: undefined })
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
const init = async () => {
|
||||
if (!pubkey) return
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
const events = await client.fetchEvents(relayList.write.slice(0, 4), [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Reaction, kinds.Repost],
|
||||
limit: 100
|
||||
},
|
||||
{
|
||||
'#P': [pubkey],
|
||||
kinds: [kinds.Zap],
|
||||
limit: 100
|
||||
}
|
||||
])
|
||||
updateNoteStatsByEvents(events)
|
||||
}
|
||||
init()
|
||||
}, [pubkey])
|
||||
|
||||
const fetchNoteLikeCount = async (event: Event) => {
|
||||
const relayList = await client.fetchRelayList(event.pubkey)
|
||||
const events = await client.fetchEvents(relayList.read.slice(0, 3), {
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Reaction],
|
||||
limit: 500
|
||||
})
|
||||
const countMap = new Map<string, number>()
|
||||
for (const e of events) {
|
||||
const targetEventId = e.tags.findLast(tagNameEquals('e'))?.[1]
|
||||
if (targetEventId) {
|
||||
countMap.set(targetEventId, (countMap.get(targetEventId) || 0) + 1)
|
||||
const fetchNoteStats = async (event: Event) => {
|
||||
const oldStats = noteStatsMap.get(event.id)
|
||||
let since: number | undefined
|
||||
if (oldStats?.updatedAt) {
|
||||
since = oldStats.updatedAt
|
||||
}
|
||||
const [relayList, authorProfile] = await Promise.all([
|
||||
client.fetchRelayList(event.pubkey),
|
||||
client.fetchProfile(event.pubkey)
|
||||
])
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Reaction],
|
||||
limit: 500
|
||||
},
|
||||
{
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Repost],
|
||||
limit: 100
|
||||
}
|
||||
]
|
||||
|
||||
if (authorProfile?.lightningAddress) {
|
||||
filters.push({
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Zap],
|
||||
limit: 500
|
||||
})
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
filters.push({
|
||||
'#e': [event.id],
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Reaction, kinds.Repost]
|
||||
})
|
||||
|
||||
if (authorProfile?.lightningAddress) {
|
||||
filters.push({
|
||||
'#e': [event.id],
|
||||
'#P': [pubkey],
|
||||
kinds: [kinds.Zap]
|
||||
})
|
||||
}
|
||||
}
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
for (const [eventId, count] of countMap) {
|
||||
const old = prev.get(eventId)
|
||||
newMap.set(
|
||||
eventId,
|
||||
old ? { ...old, likeCount: Math.max(count, old.likeCount ?? 0) } : { likeCount: count }
|
||||
)
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
return countMap.get(event.id) || 0
|
||||
}
|
||||
|
||||
const fetchNoteRepostCount = async (event: Event) => {
|
||||
const relayList = await client.fetchRelayList(event.pubkey)
|
||||
const events = await client.fetchEvents(relayList.read.slice(0, 3), {
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Repost],
|
||||
limit: 100
|
||||
})
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
const old = prev.get(event.id)
|
||||
newMap.set(
|
||||
event.id,
|
||||
old
|
||||
? { ...old, repostCount: Math.max(events.length, old.repostCount ?? 0) }
|
||||
: { repostCount: events.length }
|
||||
)
|
||||
return newMap
|
||||
})
|
||||
return events.length
|
||||
}
|
||||
|
||||
const fetchNoteLikedStatus = async (event: Event) => {
|
||||
if (!pubkey) return false
|
||||
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
const events = await client.fetchEvents(relayList.write, {
|
||||
'#e': [event.id],
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Reaction]
|
||||
})
|
||||
const likedEventIds = events
|
||||
.map((e) => e.tags.findLast(tagNameEquals('e'))?.[1])
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
likedEventIds.forEach((eventId) => {
|
||||
const old = newMap.get(eventId)
|
||||
newMap.set(eventId, old ? { ...old, hasLiked: true } : { hasLiked: true })
|
||||
if (since) {
|
||||
filters.forEach((filter) => {
|
||||
filter.since = since
|
||||
})
|
||||
if (!likedEventIds.includes(event.id)) {
|
||||
const old = newMap.get(event.id)
|
||||
newMap.set(event.id, old ? { ...old, hasLiked: false } : { hasLiked: false })
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
const events = await client.fetchEvents(relayList.read.slice(0, 4), filters)
|
||||
updateNoteStatsByEvents(events)
|
||||
let stats: Partial<TNoteStats> | undefined
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(event.id) || {}
|
||||
prev.set(event.id, { ...old, updatedAt: dayjs().unix() })
|
||||
stats = prev.get(event.id)
|
||||
return new Map(prev)
|
||||
})
|
||||
return likedEventIds.includes(event.id)
|
||||
return stats
|
||||
}
|
||||
|
||||
const fetchNoteRepostedStatus = async (event: Event) => {
|
||||
if (!pubkey) return false
|
||||
const updateNoteStatsByEvents = (events: Event[]) => {
|
||||
const newRepostsMap = new Map<string, Set<string>>()
|
||||
const newLikesMap = new Map<string, Set<string>>()
|
||||
const newZapsMap = new Map<
|
||||
string,
|
||||
{ pr: string; pubkey: string; amount: number; comment?: string }[]
|
||||
>()
|
||||
events.forEach((evt) => {
|
||||
if (evt.kind === kinds.Repost) {
|
||||
const eventId = evt.tags.find(tagNameEquals('e'))?.[1]
|
||||
if (!eventId) return
|
||||
const newReposts = newRepostsMap.get(eventId) || new Set()
|
||||
newReposts.add(evt.pubkey)
|
||||
newRepostsMap.set(eventId, newReposts)
|
||||
return
|
||||
}
|
||||
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
const events = await client.fetchEvents(relayList.write, {
|
||||
'#e': [event.id],
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Repost]
|
||||
if (evt.kind === kinds.Reaction) {
|
||||
const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1]
|
||||
if (targetEventId) {
|
||||
const newLikes = newLikesMap.get(targetEventId) || new Set()
|
||||
newLikes.add(evt.pubkey)
|
||||
newLikesMap.set(targetEventId, newLikes)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.kind === kinds.Zap) {
|
||||
const info = extractZapInfoFromReceipt(evt)
|
||||
if (!info) return
|
||||
const { eventId, senderPubkey, invoice, amount, comment } = info
|
||||
if (!eventId || !senderPubkey) return
|
||||
const newZaps = newZapsMap.get(eventId) || []
|
||||
newZaps.push({ pr: invoice, pubkey: senderPubkey, amount, comment })
|
||||
newZapsMap.set(eventId, newZaps)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
setNoteStatsMap((prev) => {
|
||||
const hasReposted = events.length > 0
|
||||
const newMap = new Map(prev)
|
||||
const old = prev.get(event.id)
|
||||
newMap.set(event.id, old ? { ...old, hasReposted } : { hasReposted })
|
||||
return newMap
|
||||
newRepostsMap.forEach((newReposts, eventId) => {
|
||||
const old = prev.get(eventId) || {}
|
||||
const reposts = old.reposts || new Set()
|
||||
newReposts.forEach((repost) => reposts.add(repost))
|
||||
prev.set(eventId, { ...old, reposts })
|
||||
})
|
||||
newLikesMap.forEach((newLikes, eventId) => {
|
||||
const old = prev.get(eventId) || {}
|
||||
const likes = old.likes || new Set()
|
||||
newLikes.forEach((like) => likes.add(like))
|
||||
prev.set(eventId, { ...old, likes })
|
||||
})
|
||||
newZapsMap.forEach((newZaps, eventId) => {
|
||||
const old = prev.get(eventId) || {}
|
||||
const zaps = old.zaps || []
|
||||
const exists = new Set(zaps.map((zap) => zap.pr))
|
||||
newZaps.forEach((zap) => {
|
||||
if (!exists.has(zap.pr)) {
|
||||
exists.add(zap.pr)
|
||||
zaps.push(zap)
|
||||
}
|
||||
})
|
||||
zaps.sort((a, b) => b.amount - a.amount)
|
||||
prev.set(eventId, { ...old, zaps })
|
||||
})
|
||||
return new Map(prev)
|
||||
})
|
||||
return events.length > 0
|
||||
return
|
||||
}
|
||||
|
||||
const updateNoteReplyCount = (noteId: string, replyCount: number) => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(noteId)
|
||||
if (!old) {
|
||||
return new Map(prev).set(noteId, { replyCount })
|
||||
prev.set(noteId, { replyCount })
|
||||
return new Map(prev)
|
||||
} else if (old.replyCount === undefined || old.replyCount < replyCount) {
|
||||
return new Map(prev).set(noteId, { ...old, replyCount })
|
||||
prev.set(noteId, { ...old, replyCount })
|
||||
return new Map(prev)
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const markNoteAsLiked = (noteId: string) => {
|
||||
const addZap = (eventId: string, pr: string, amount: number, comment?: string) => {
|
||||
if (!pubkey) return
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(noteId)
|
||||
return new Map(prev).set(
|
||||
noteId,
|
||||
old
|
||||
? { ...old, hasLiked: true, likeCount: (old.likeCount ?? 0) + 1 }
|
||||
: { hasLiked: true, likeCount: 1 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const markNoteAsReposted = (noteId: string) => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(noteId)
|
||||
return new Map(prev).set(
|
||||
noteId,
|
||||
old
|
||||
? { ...old, hasReposted: true, repostCount: (old.repostCount ?? 0) + 1 }
|
||||
: { hasReposted: true, repostCount: 1 }
|
||||
)
|
||||
const old = prev.get(eventId)
|
||||
const zaps = old?.zaps || []
|
||||
prev.set(eventId, {
|
||||
...old,
|
||||
zaps: [...zaps, { pr, pubkey, amount, comment }].sort((a, b) => b.amount - a.amount)
|
||||
})
|
||||
return new Map(prev)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -184,13 +221,10 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
|
||||
<NoteStatsContext.Provider
|
||||
value={{
|
||||
noteStatsMap,
|
||||
fetchNoteLikeCount,
|
||||
fetchNoteLikedStatus,
|
||||
fetchNoteRepostCount,
|
||||
fetchNoteRepostedStatus,
|
||||
fetchNoteStats,
|
||||
updateNoteReplyCount,
|
||||
markNoteAsLiked,
|
||||
markNoteAsReposted
|
||||
addZap,
|
||||
updateNoteStatsByEvents
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
143
src/providers/NotificationProvider.tsx
Normal file
143
src/providers/NotificationProvider.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND } from '@/constants'
|
||||
import { TPrimaryPageName, usePrimaryPage } from '@/PageManager'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { SubCloser } from 'nostr-tools/abstract-pool'
|
||||
import { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useNostr } from './NostrProvider'
|
||||
|
||||
type TNotificationContext = {
|
||||
hasNewNotification: boolean
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<TNotificationContext | undefined>(undefined)
|
||||
|
||||
export const useNotification = () => {
|
||||
const context = useContext(NotificationContext)
|
||||
if (!context) {
|
||||
throw new Error('useNotification must be used within a NotificationProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const { pubkey } = useNostr()
|
||||
const { current } = usePrimaryPage()
|
||||
const [hasNewNotification, setHasNewNotification] = useState(false)
|
||||
const [lastReadTime, setLastReadTime] = useState(-1)
|
||||
const previousPageRef = useRef<TPrimaryPageName | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (current !== 'notifications' && previousPageRef.current === 'notifications') {
|
||||
// navigate from notifications to other pages
|
||||
setLastReadTime(dayjs().unix())
|
||||
setHasNewNotification(false)
|
||||
} else if (current === 'notifications' && previousPageRef.current !== null) {
|
||||
// navigate to notifications
|
||||
setHasNewNotification(false)
|
||||
}
|
||||
previousPageRef.current = current
|
||||
}, [current])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey || lastReadTime < 0) return
|
||||
storage.setLastReadNotificationTime(pubkey, lastReadTime)
|
||||
}, [lastReadTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey) return
|
||||
setLastReadTime(storage.getLastReadNotificationTime(pubkey))
|
||||
setHasNewNotification(false)
|
||||
}, [pubkey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey || lastReadTime < 0) return
|
||||
|
||||
// Track if component is mounted
|
||||
const isMountedRef = { current: true }
|
||||
let currentSubCloser: SubCloser | null = null
|
||||
|
||||
const subscribe = async () => {
|
||||
if (!isMountedRef.current) return null
|
||||
|
||||
try {
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
const relayUrls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 4)
|
||||
const subCloser = client.subscribe(
|
||||
relayUrls,
|
||||
[
|
||||
{
|
||||
kinds: [
|
||||
kinds.ShortTextNote,
|
||||
COMMENT_EVENT_KIND,
|
||||
kinds.Reaction,
|
||||
kinds.Repost,
|
||||
kinds.Zap
|
||||
],
|
||||
'#p': [pubkey],
|
||||
since: lastReadTime ?? dayjs().unix(),
|
||||
limit: 10
|
||||
}
|
||||
],
|
||||
{
|
||||
onevent: (evt) => {
|
||||
if (evt.pubkey !== pubkey) {
|
||||
setHasNewNotification(true)
|
||||
subCloser.close()
|
||||
}
|
||||
},
|
||||
onclose: (reasons) => {
|
||||
if (reasons.every((reason) => reason === 'closed by caller')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only reconnect if still mounted and not a manual close
|
||||
if (isMountedRef.current && currentSubCloser) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
subscribe()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
currentSubCloser = subCloser
|
||||
return subCloser
|
||||
} catch (error) {
|
||||
console.error('Subscription error:', error)
|
||||
|
||||
// Retry on error if still mounted
|
||||
if (isMountedRef.current) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
subscribe()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Initial subscription
|
||||
subscribe()
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
if (currentSubCloser) {
|
||||
currentSubCloser.close()
|
||||
currentSubCloser = null
|
||||
}
|
||||
}
|
||||
}, [lastReadTime, pubkey])
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ hasNewNotification }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
57
src/providers/ZapProvider.tsx
Normal file
57
src/providers/ZapProvider.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import storage from '@/services/local-storage.service'
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
|
||||
type TZapContext = {
|
||||
defaultZapSats: number
|
||||
updateDefaultSats: (sats: number) => void
|
||||
defaultZapComment: string
|
||||
updateDefaultComment: (comment: string) => void
|
||||
quickZap: boolean
|
||||
updateQuickZap: (quickZap: boolean) => void
|
||||
}
|
||||
|
||||
const ZapContext = createContext<TZapContext | undefined>(undefined)
|
||||
|
||||
export const useZap = () => {
|
||||
const context = useContext(ZapContext)
|
||||
if (!context) {
|
||||
throw new Error('useZap must be used within a ZapProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ZapProvider({ children }: { children: React.ReactNode }) {
|
||||
const [defaultZapSats, setDefaultZapSats] = useState<number>(storage.getDefaultZapSats())
|
||||
const [defaultZapComment, setDefaultZapComment] = useState<string>(storage.getDefaultZapComment())
|
||||
const [quickZap, setQuickZap] = useState<boolean>(storage.getQuickZap())
|
||||
|
||||
const updateDefaultSats = (sats: number) => {
|
||||
storage.setDefaultZapSats(sats)
|
||||
setDefaultZapSats(sats)
|
||||
}
|
||||
|
||||
const updateDefaultComment = (comment: string) => {
|
||||
storage.setDefaultZapComment(comment)
|
||||
setDefaultZapComment(comment)
|
||||
}
|
||||
|
||||
const updateQuickZap = (quickZap: boolean) => {
|
||||
storage.setQuickZap(quickZap)
|
||||
setQuickZap(quickZap)
|
||||
}
|
||||
|
||||
return (
|
||||
<ZapContext.Provider
|
||||
value={{
|
||||
defaultZapSats,
|
||||
updateDefaultSats,
|
||||
defaultZapComment,
|
||||
updateDefaultComment,
|
||||
quickZap,
|
||||
updateQuickZap
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ZapContext.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user