diff --git a/src/components/Profile/ProfileBadges.tsx b/src/components/Profile/ProfileBadges.tsx index d2424a18..4396cdd5 100644 --- a/src/components/Profile/ProfileBadges.tsx +++ b/src/components/Profile/ProfileBadges.tsx @@ -1,3 +1,4 @@ +import { RefreshButton } from '@/components/RefreshButton' import { Skeleton } from '@/components/ui/skeleton' import { useProfileWall } from '@/hooks/useProfileWall' import { useTranslation } from 'react-i18next' @@ -10,7 +11,7 @@ export default function ProfileBadges({ profileEventId?: string }) { const { t } = useTranslation() - const { badges, isLoading } = useProfileWall(pubkey, profileEventId) + const { badges, isLoading, refresh } = useProfileWall(pubkey, profileEventId) if (isLoading && badges.length === 0) { return ( @@ -25,6 +26,9 @@ export default function ProfileBadges({ return (
+
+ +
{badges.map((badge) => (
{ + setIsRefreshing(true) + refresh() + } + if (isLoading && received.length === 0 && made.length === 0) { return (
@@ -38,6 +44,9 @@ export function ProfileReportsPanel({ pubkey }: { pubkey: string }) { return (
+
+ +
{isRefreshing && (
{t('Profile reports dialog description')}
- + {open ? : null}
diff --git a/src/hooks/useProfileReportsEvents.tsx b/src/hooks/useProfileReportsEvents.tsx index ffc0573c..4b4c5631 100644 --- a/src/hooks/useProfileReportsEvents.tsx +++ b/src/hooks/useProfileReportsEvents.tsx @@ -1,7 +1,7 @@ import { ExtendedKind } from '@/constants' import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import type { ProfileTimelineRelayUrlsBuilder } from '@/hooks/useProfileTimeline' -import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' +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' @@ -123,19 +123,30 @@ export function useProfileReportsEvents({ 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 - if (custom) { - return custom( - favoriteRelaysRef.current, - blockedRelaysRef.current, - authorRelayList, - includeAuthorLocal - ) - } + 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, - blockedRelaysRef.current, - authorRelayList, + blocked, + { read: [], write: [], httpRead: [], httpWrite: [] }, false, includeAuthorLocal, [...REPORT_KINDS], diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx index 79e477a5..0ca6bc7c 100644 --- a/src/hooks/useProfileWall.tsx +++ b/src/hooks/useProfileWall.tsx @@ -1,10 +1,17 @@ -import { ExtendedKind } from '@/constants' +import { + ExtendedKind, + METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, + METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS +} from '@/constants' import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { getReplaceableCoordinate } from '@/lib/event' +import { + fetchLegacyProfileBadgesListEvent, + fetchProfileBadgesListEvent +} from '@/lib/nip58-profile-badges-list' import { isNip58ProfileBadgesListEvent, - LEGACY_PROFILE_BADGES_D_TAG, parseAddressableCoordinate, parseProfileBadgeEntries, resolveBadgeDisplayFromDefinition, @@ -17,9 +24,56 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import client, { replaceableEventService } from '@/services/client.service' import { ReplaceableEventService } from '@/services/client-replaceable-events.service' +import indexedDb from '@/services/indexed-db.service' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Event, kinds, type Filter } from 'nostr-tools' +async function fetchBadgeDefinitionOnRelays( + coordinate: string, + relayUrls: string[] +): Promise { + const parsed = parseAddressableCoordinate(coordinate) + if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) return undefined + + try { + const disk = await indexedDb.getReplaceableEvent(parsed.pubkey, parsed.kind, parsed.d) + if (disk) return disk + } catch { + /* best-effort */ + } + + try { + const cached = await replaceableEventService.fetchReplaceableEvent( + parsed.pubkey, + parsed.kind, + parsed.d + ) + if (cached) return cached + } catch { + /* best-effort */ + } + + if (!relayUrls.length) return undefined + + const rows = await client.fetchEvents( + relayUrls, + { + authors: [parsed.pubkey], + kinds: [ExtendedKind.BADGE_DEFINITION], + '#d': [parsed.d], + limit: 20 + }, + { + replaceableRace: true, + eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, + globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS + } + ) + const matches = rows.filter((e) => e.kind === ExtendedKind.BADGE_DEFINITION) + if (!matches.length) return undefined + return matches.reduce((best, e) => (e.created_at > best.created_at ? e : best)) +} + const CACHE_DURATION = 5 * 60 * 1000 const wallCacheByKey = new Map() @@ -90,16 +144,10 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine useGlobalRelayBootstrapRef.current ) - // --- Badges (NIP-58) --- - let listEvent = - (await replaceableEventService.fetchReplaceableEvent(pkNorm, ExtendedKind.PROFILE_BADGES_LIST)) ?? - undefined + // --- Badges (NIP-58): IndexedDB + profile read relays (favorites / fast-read), not inbox-only --- + let listEvent = await fetchProfileBadgesListEvent(pkNorm, relayUrls) if (!listEvent || !isNip58ProfileBadgesListEvent(listEvent)) { - const legacy = await replaceableEventService.fetchReplaceableEvent( - pkNorm, - ExtendedKind.PROFILE_BADGES, - LEGACY_PROFILE_BADGES_D_TAG - ) + const legacy = await fetchLegacyProfileBadgesListEvent(pkNorm, relayUrls) if (legacy && isNip58ProfileBadgesListEvent(legacy)) listEvent = legacy } @@ -109,17 +157,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine 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) + defByCoord.set(coord, await fetchBadgeDefinitionOnRelays(coord, relayUrls)) }) )