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.
189 lines
6.9 KiB
189 lines
6.9 KiB
import { ExtendedKind } from '@/constants' |
|
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' |
|
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' |
|
import { getReplaceableCoordinate } from '@/lib/event' |
|
import { |
|
isNip58ProfileBadgesListEvent, |
|
LEGACY_PROFILE_BADGES_D_TAG, |
|
parseAddressableCoordinate, |
|
parseProfileBadgeEntries, |
|
resolveBadgeDisplayFromDefinition, |
|
type ResolvedProfileBadge |
|
} from '@/lib/nip58-profile-badges' |
|
import { isDirectProfileWallComment } from '@/lib/profile-wall-comments' |
|
import { normalizeHexPubkey } from '@/lib/pubkey' |
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
import { useDeletedEvent } from '@/providers/DeletedEventProvider' |
|
import client, { replaceableEventService } from '@/services/client.service' |
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
|
import { Event, kinds, type Filter } from 'nostr-tools' |
|
|
|
const CACHE_DURATION = 5 * 60 * 1000 |
|
const wallCacheByKey = new Map<string, { badges: ResolvedProfileBadge[]; comments: Event[]; lastUpdated: number }>() |
|
|
|
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}` |
|
} |
|
|
|
export function useProfileWall(pubkey: string, profileEventId: string | undefined) { |
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() |
|
const { isEventDeleted } = useDeletedEvent() |
|
const isEventDeletedRef = useRef(isEventDeleted) |
|
isEventDeletedRef.current = isEventDeleted |
|
|
|
const cacheKey = useMemo(() => `${pubkey}-profile-wall-v1`, [pubkey]) |
|
const cached = wallCacheByKey.get(cacheKey) |
|
|
|
const [badges, setBadges] = useState<ResolvedProfileBadge[]>(cached?.badges ?? []) |
|
const [comments, setComments] = useState<Event[]>(cached?.comments ?? []) |
|
const [isLoading, setIsLoading] = useState(!cached) |
|
const [refreshToken, setRefreshToken] = useState(0) |
|
|
|
const relayListsKey = useMemo( |
|
() => relayListsContentKey(favoriteRelays, blockedRelays), |
|
[favoriteRelays, blockedRelays] |
|
) |
|
const favoriteRelaysRef = useRef(favoriteRelays) |
|
const blockedRelaysRef = useRef(blockedRelays) |
|
favoriteRelaysRef.current = favoriteRelays |
|
blockedRelaysRef.current = blockedRelays |
|
const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap) |
|
useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap |
|
|
|
useEffect(() => { |
|
let cancelled = false |
|
|
|
const run = async () => { |
|
const mem = wallCacheByKey.get(cacheKey) |
|
if (mem && Date.now() - mem.lastUpdated < CACHE_DURATION && refreshToken === 0) { |
|
setBadges(mem.badges) |
|
setComments(mem.comments) |
|
setIsLoading(false) |
|
return |
|
} |
|
|
|
setIsLoading(true) |
|
|
|
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 authorRl = await client.fetchRelayList(pubkey).catch(() => emptyAuthor) |
|
if (cancelled) return |
|
|
|
const relayUrls = buildProfilePageReadRelayUrls( |
|
favoriteRelaysRef.current, |
|
blockedRelaysRef.current, |
|
authorRl, |
|
false, |
|
false, |
|
[ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION], |
|
useGlobalRelayBootstrapRef.current |
|
) |
|
|
|
// --- Badges (NIP-58) --- |
|
let listEvent = |
|
(await replaceableEventService.fetchReplaceableEvent(pkNorm, ExtendedKind.PROFILE_BADGES_LIST)) ?? |
|
undefined |
|
if (!listEvent || !isNip58ProfileBadgesListEvent(listEvent)) { |
|
const legacy = await replaceableEventService.fetchReplaceableEvent( |
|
pkNorm, |
|
ExtendedKind.PROFILE_BADGES, |
|
LEGACY_PROFILE_BADGES_D_TAG |
|
) |
|
if (legacy && isNip58ProfileBadgesListEvent(legacy)) listEvent = legacy |
|
} |
|
|
|
const entries = parseProfileBadgeEntries(listEvent) |
|
const defCoords = [...new Set(entries.map((e) => e.definitionCoordinate))] |
|
const defByCoord = new Map<string, Event | undefined>() |
|
|
|
await Promise.all( |
|
defCoords.map(async (coord) => { |
|
const parsed = parseAddressableCoordinate(coord) |
|
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) { |
|
defByCoord.set(coord, undefined) |
|
return |
|
} |
|
const defEvent = await replaceableEventService.fetchReplaceableEvent( |
|
parsed.pubkey, |
|
parsed.kind, |
|
parsed.d |
|
) |
|
defByCoord.set(coord, defEvent) |
|
}) |
|
) |
|
|
|
const resolvedBadges = entries.map((entry) => |
|
resolveBadgeDisplayFromDefinition(entry, defByCoord.get(entry.definitionCoordinate)) |
|
) |
|
|
|
// --- Wall comments (kind 1111 on profile kind 0) --- |
|
let wallComments: Event[] = [] |
|
const profileId = profileEventId?.trim().toLowerCase() |
|
if (profileId && /^[0-9a-f]{64}$/.test(profileId) && relayUrls.length > 0) { |
|
const profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '') |
|
const filters: Filter[] = [ |
|
{ kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 }, |
|
{ kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 } |
|
] |
|
const pool = new Map<string, Event>() |
|
try { |
|
const rows = await Promise.all( |
|
filters.map((filter) => |
|
client.fetchEvents(relayUrls, filter, { |
|
cache: true, |
|
eoseTimeout: 4500, |
|
globalTimeout: 14_000 |
|
}) |
|
) |
|
) |
|
for (const batch of rows) { |
|
for (const e of batch) pool.set(e.id, e) |
|
} |
|
} catch { |
|
/* ignore */ |
|
} |
|
|
|
wallComments = [...pool.values()] |
|
.filter( |
|
(e) => |
|
!isEventDeletedRef.current(e) && |
|
isDirectProfileWallComment(e, profileId, pkNorm) |
|
) |
|
.sort((a, b) => b.created_at - a.created_at) |
|
} |
|
|
|
if (cancelled) return |
|
setBadges(resolvedBadges) |
|
setComments(wallComments) |
|
wallCacheByKey.set(cacheKey, { |
|
badges: resolvedBadges, |
|
comments: wallComments, |
|
lastUpdated: Date.now() |
|
}) |
|
setIsLoading(false) |
|
} |
|
|
|
void run() |
|
return () => { |
|
cancelled = true |
|
} |
|
}, [pubkey, profileEventId, cacheKey, refreshToken, relayListsKey]) |
|
|
|
const refresh = useCallback(() => { |
|
wallCacheByKey.delete(cacheKey) |
|
setIsLoading(true) |
|
setRefreshToken((t) => t + 1) |
|
}, [cacheKey]) |
|
|
|
return { badges, comments, isLoading, refresh } |
|
}
|
|
|