You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

356 lines
12 KiB

import { ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import type { ProfileTimelineRelayUrlsBuilder } from '@/hooks/useProfileTimeline'
import { buildProfilePageReadRelayUrls, mergeRelayUrlLayers } from '@/lib/favorites-feed-relays'
import { isNip56ReportEvent } from '@/lib/event'
import { isReportAuthoredBy, reportTargetsPubkey } from '@/lib/nip56-reports'
import { normalizeHexPubkey } from '@/lib/pubkey'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context'
import client from '@/services/client.service'
import type { TSubRequestFilter } from '@/types'
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { Event, kinds } from 'nostr-tools'
const REPORT_KINDS = [kinds.Report, ExtendedKind.REPORT] as const
const CACHE_DURATION = 5 * 60 * 1000
type CacheEntry = { events: Event[]; lastUpdated: number }
const memoryByKey = new Map<string, CacheEntry>()
function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string {
const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001')
const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001')
return `${fav}\u0000${blk}`
}
function mergeReportEvents(
raw: Event[],
limit: number,
isEventDeleted: (e: Event) => boolean,
extraFilter?: (e: Event) => boolean
): Event[] {
const dedup = new Map<string, Event>()
for (const e of raw) {
if (!isNip56ReportEvent(e)) continue
if (extraFilter && !extraFilter(e)) continue
if (isEventDeleted(e)) continue
dedup.set(e.id, e)
}
return [...dedup.values()].sort((a, b) => b.created_at - a.created_at).slice(0, limit)
}
function eventsEqualById(a: Event[], b: Event[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i].id !== b[i].id) return false
}
return true
}
type FetchMode = 'received' | 'made'
function buildFilter(pubkey: string, mode: FetchMode, limit: number): TSubRequestFilter {
if (mode === 'made') {
return { authors: [pubkey], kinds: [...REPORT_KINDS], limit }
}
return { kinds: [...REPORT_KINDS], '#p': [pubkey], limit }
}
function postFilter(pubkey: string, mode: FetchMode) {
return mode === 'made'
? (e: Event) => isReportAuthoredBy(e, pubkey)
: (e: Event) => reportTargetsPubkey(e, pubkey)
}
type UseProfileReportsEventsOptions = {
pubkey: string
relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder
limit?: number
}
export function useProfileReportsEvents({
pubkey,
relayUrlsBuilder,
limit = 200
}: UseProfileReportsEventsOptions) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
const nostr = useNostrOptional()
const { isEventDeleted, tombstoneEpoch } = useDeletedEvent()
const isEventDeletedRef = useRef(isEventDeleted)
isEventDeletedRef.current = isEventDeleted
const receivedCacheKey = useMemo(() => `${pubkey}-profile-reports-received-v1`, [pubkey])
const madeCacheKey = useMemo(() => `${pubkey}-profile-reports-made-v1`, [pubkey])
const receivedCached = memoryByKey.get(receivedCacheKey)
const madeCached = memoryByKey.get(madeCacheKey)
const reportsCacheHasRows =
(receivedCached?.events.length ?? 0) + (madeCached?.events.length ?? 0) > 0
const reportsCacheFresh =
!!receivedCached &&
!!madeCached &&
Date.now() - receivedCached.lastUpdated < CACHE_DURATION &&
Date.now() - madeCached.lastUpdated < CACHE_DURATION
const hasUsefulReportsCache = reportsCacheHasRows && reportsCacheFresh
const [received, setReceived] = useState<Event[]>(
hasUsefulReportsCache ? receivedCached!.events : []
)
const [made, setMade] = useState<Event[]>(hasUsefulReportsCache ? madeCached!.events : [])
const [isLoading, setIsLoading] = useState(!hasUsefulReportsCache)
const [refreshToken, setRefreshToken] = useState(0)
const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim()
if (!me) return false
try {
return normalizeHexPubkey(me) === normalizeHexPubkey(pubkey)
} catch {
return false
}
}, [nostr?.pubkey, pubkey])
const relayListsKey = useMemo(
() => relayListsContentKey(favoriteRelays, blockedRelays),
[favoriteRelays, blockedRelays]
)
const relayUrlsBuilderRef = useRef(relayUrlsBuilder)
relayUrlsBuilderRef.current = relayUrlsBuilder
const favoriteRelaysRef = useRef(favoriteRelays)
const blockedRelaysRef = useRef(blockedRelays)
favoriteRelaysRef.current = favoriteRelays
blockedRelaysRef.current = blockedRelays
const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap)
useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap
const runGenRef = useRef(0)
const resolveFeedUrls = useCallback(
(
authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
includeAuthorLocal: boolean
) => {
const blocked = blockedRelaysRef.current
const profileRead = buildProfilePageReadRelayUrls(
favoriteRelaysRef.current,
blocked,
authorRelayList,
false,
includeAuthorLocal,
[...REPORT_KINDS],
useGlobalRelayBootstrapRef.current
)
const custom = relayUrlsBuilderRef.current
const fromCustom = custom
? custom(favoriteRelaysRef.current, blocked, authorRelayList, includeAuthorLocal)
: []
const merged = mergeRelayUrlLayers(
custom ? [fromCustom, profileRead] : [profileRead],
blocked
)
if (merged.length > 0) return merged
// NIP-65 still loading: favorites + fast-read only (same as profile feed).
return buildProfilePageReadRelayUrls(
favoriteRelaysRef.current,
blocked,
{ read: [], write: [], httpRead: [], httpWrite: [] },
false,
includeAuthorLocal,
[...REPORT_KINDS],
useGlobalRelayBootstrapRef.current
)
},
[]
)
useEffect(() => {
setReceived((prev) => {
const next = prev.filter((e) => !isEventDeletedRef.current(e))
const c = memoryByKey.get(receivedCacheKey)
if (c) memoryByKey.set(receivedCacheKey, { events: next, lastUpdated: c.lastUpdated })
return next
})
setMade((prev) => {
const next = prev.filter((e) => !isEventDeletedRef.current(e))
const c = memoryByKey.get(madeCacheKey)
if (c) memoryByKey.set(madeCacheKey, { events: next, lastUpdated: c.lastUpdated })
return next
})
}, [tombstoneEpoch, receivedCacheKey, madeCacheKey])
useEffect(() => {
let cancelled = false
const runGen = ++runGenRef.current
const loadMode = async (
mode: FetchMode,
cacheKey: string,
setEvents: Dispatch<SetStateAction<Event[]>>
) => {
const mem = memoryByKey.get(cacheKey)
const cacheAge = mem ? Date.now() - mem.lastUpdated : Infinity
const isCacheFresh = cacheAge < CACHE_DURATION
const pool = new Map<string, Event>()
if (isCacheFresh && mem) {
mem.events.forEach((e) => pool.set(e.id, e))
}
const flush = () => {
if (cancelled) return
const processed = mergeReportEvents(
Array.from(pool.values()),
limit,
isEventDeletedRef.current,
postFilter(pubkey, mode)
)
if (processed.length > 0) {
memoryByKey.set(cacheKey, { events: processed, lastUpdated: Date.now() })
} else {
memoryByKey.delete(cacheKey)
}
setEvents((prev) => (eventsEqualById(prev, processed) ? prev : processed))
}
let pkNorm = pubkey
try {
pkNorm = normalizeHexPubkey(pubkey)
} catch {
/* use raw */
}
const emptyAuthor = { read: [] as string[], write: [] as string[], httpRead: [] as string[], httpWrite: [] as string[] }
const authorPeek = await client.peekRelayListFromStorage(pubkey).catch(() => emptyAuthor)
if (cancelled) return
let provisionalUrls = resolveFeedUrls(authorPeek, includeAuthorLocalRelays)
if (provisionalUrls.length === 0) {
provisionalUrls = dedupeNormalizeRelayUrlsOrdered([
...PROFILE_RELAY_URLS,
...FAST_READ_RELAY_URLS
]).slice(0, 24)
}
if (provisionalUrls.length === 0) return
const filter = buildFilter(pkNorm, mode, limit)
const subRequests = [{ urls: provisionalUrls, filter }]
try {
const disk = await client.getLocalFeedEvents(subRequests)
if (!cancelled) {
for (const e of disk) pool.set(e.id, e)
flush()
}
} catch {
/* best-effort */
}
try {
const fetched = await client.fetchEvents(provisionalUrls, filter, {
cache: true,
eoseTimeout: 4500,
globalTimeout: 14_000,
foreground: true
})
if (!cancelled) {
for (const e of fetched) pool.set(e.id, e)
flush()
}
} catch {
/* ignore */
}
const authorRl = await client.fetchRelayList(pubkey).catch(() => emptyAuthor)
if (cancelled) return
const fullUrls = resolveFeedUrls(authorRl, includeAuthorLocalRelays)
const deltaUrls = subtractNormalizedRelayUrls(fullUrls, provisionalUrls)
if (deltaUrls.length === 0) return
const deltaRequests = [{ urls: deltaUrls, filter }]
try {
const diskDelta = await client.getLocalFeedEvents(deltaRequests)
if (!cancelled) {
for (const e of diskDelta) pool.set(e.id, e)
flush()
}
} catch {
/* ignore */
}
try {
const fetchedDelta = await client.fetchEvents(deltaUrls, filter, {
cache: true,
eoseTimeout: 4500,
globalTimeout: 14_000,
foreground: true
})
if (!cancelled) {
for (const e of fetchedDelta) pool.set(e.id, e)
flush()
}
} catch {
/* ignore */
}
}
const run = async () => {
const recvMem = memoryByKey.get(receivedCacheKey)
const madeMem = memoryByKey.get(madeCacheKey)
const recvFresh = recvMem && Date.now() - recvMem.lastUpdated < CACHE_DURATION
const madeFresh = madeMem && Date.now() - madeMem.lastUpdated < CACHE_DURATION
const cachedAny =
(recvMem?.events.length ?? 0) + (madeMem?.events.length ?? 0) > 0
if (recvFresh && recvMem) {
setReceived(recvMem.events)
} else if (recvMem?.events.length === 0) {
memoryByKey.delete(receivedCacheKey)
}
if (madeFresh && madeMem) {
setMade(madeMem.events)
} else if (madeMem?.events.length === 0) {
memoryByKey.delete(madeCacheKey)
}
if (recvFresh && madeFresh && refreshToken === 0 && cachedAny) {
if (runGen === runGenRef.current) setIsLoading(false)
return
}
setIsLoading(true)
try {
await Promise.all([
loadMode('received', receivedCacheKey, setReceived),
loadMode('made', madeCacheKey, setMade)
])
} finally {
if (!cancelled && runGen === runGenRef.current) setIsLoading(false)
}
}
void run()
return () => {
cancelled = true
}
}, [
pubkey,
receivedCacheKey,
madeCacheKey,
limit,
refreshToken,
relayListsKey,
includeAuthorLocalRelays,
resolveFeedUrls
])
const refresh = useCallback(() => {
memoryByKey.delete(receivedCacheKey)
memoryByKey.delete(madeCacheKey)
setIsLoading(true)
setRefreshToken((t) => t + 1)
}, [receivedCacheKey, madeCacheKey])
return { received, made, isLoading, refresh }
}