diff --git a/src/components/Profile/ProfileHeaderInteractions.tsx b/src/components/Profile/ProfileHeaderInteractions.tsx index 0c1a3085..ea8bf60d 100644 --- a/src/components/Profile/ProfileHeaderInteractions.tsx +++ b/src/components/Profile/ProfileHeaderInteractions.tsx @@ -8,7 +8,8 @@ import Emoji from '@/components/Emoji' import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import type { TProfileZap } from '@/hooks/useProfileInteractions' import type { TProfileBadge } from '@/hooks/useProfileBadges' -import { Zap, MessageCircle, ThumbsUp } from 'lucide-react' +import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks' +import { Zap, MessageCircle, ThumbsUp, Users } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton' import { useTranslation } from 'react-i18next' import { Event } from 'nostr-tools' @@ -18,8 +19,10 @@ type Props = { reactions: Event[] comments: Event[] badges: TProfileBadge[] + followPacks: TProfileFollowPack[] loading: boolean badgesLoading: boolean + followPacksLoading: boolean } const ZAPS_PER_ROW = 4 @@ -28,6 +31,7 @@ const MAX_ZAPS = ZAPS_PER_ROW * ZAP_ROWS const BADGES_PER_ROW = 4 const BADGE_ROWS = 2 const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS +const MAX_FOLLOW_PACKS = 8 function ZapBadge({ zap }: { zap: TProfileZap }) { const { push } = useSecondaryPage() @@ -85,6 +89,21 @@ function CommentBadge({ event }: { event: Event }) { ) } +function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) { + const { push } = useSecondaryPage() + return ( + + ) +} + function BadgeItem({ badge }: { badge: TProfileBadge }) { const imageUrl = badge.thumb ?? badge.image const label = badge.name ?? badge.a.split(':').pop() ?? '' @@ -116,10 +135,20 @@ function BadgeItem({ badge }: { badge: TProfileBadge }) { ) } -export default function ProfileHeaderInteractions({ zaps, reactions, comments, badges, loading, badgesLoading }: Props) { +export default function ProfileHeaderInteractions({ + zaps, + reactions, + comments, + badges, + followPacks, + loading, + badgesLoading, + followPacksLoading +}: Props) { const { t } = useTranslation() const displayZaps = zaps.slice(0, MAX_ZAPS) const displayBadges = badges.slice(0, MAX_BADGES) + const displayFollowPacks = followPacks.slice(0, MAX_FOLLOW_PACKS) const Section = ({ title, isEmpty, isLoading, children, skeletonCount = 6 }: { title: string @@ -174,6 +203,13 @@ export default function ProfileHeaderInteractions({ zaps, reactions, comments, b ))} +
+
+ {displayFollowPacks.map((pack) => ( + + ))} +
+
) } diff --git a/src/components/Profile/ProfileInteractionsAccordion.tsx b/src/components/Profile/ProfileInteractionsAccordion.tsx new file mode 100644 index 00000000..1b5c742d --- /dev/null +++ b/src/components/Profile/ProfileInteractionsAccordion.tsx @@ -0,0 +1,110 @@ +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { Skeleton } from '@/components/ui/skeleton' +import { ChevronDown } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { useEffect } from 'react' +import { useProfileRelayUrls } from '@/hooks/useProfileRelayUrls' +import { useProfileInteractions } from '@/hooks/useProfileInteractions' +import { useProfileBadges } from '@/hooks/useProfileBadges' +import { useProfileFollowPacks } from '@/hooks/useProfileFollowPacks' +import ProfileHeaderInteractions from './ProfileHeaderInteractions' + +type Props = { + pubkey: string | undefined + isExpanded: boolean + onExpandedChange: (open: boolean) => void + onRefreshReady?: (refresh: (() => void) | null) => void +} + +function ProfileInteractionsContent({ pubkey, relayUrls, onRefreshReady }: { + pubkey: string + relayUrls: string[] | undefined + onRefreshReady?: (refresh: (() => void) | null) => void +}) { + const { zaps, reactions, comments, loading, refresh } = useProfileInteractions(pubkey, relayUrls) + const { badges, loading: badgesLoading, refresh: refreshBadges } = useProfileBadges(pubkey, relayUrls) + const { packs, loading: followPacksLoading, refresh: refreshFollowPacks } = useProfileFollowPacks(pubkey, relayUrls) + + useEffect(() => { + const doRefresh = () => { + refresh() + refreshBadges() + refreshFollowPacks() + } + onRefreshReady?.(doRefresh) + return () => { onRefreshReady?.(null) } + }, [refresh, refreshBadges, refreshFollowPacks, onRefreshReady]) + + return ( + + ) +} + +function ProfileInteractionsSkeleton() { + return ( +
+ {[6, 4, 4, 8, 6].map((count, i) => ( +
+ +
+ {Array.from({ length: count }).map((_, j) => ( + + ))} +
+
+ ))} +
+ ) +} + +export default function ProfileInteractionsAccordion({ + pubkey, + isExpanded, + onExpandedChange, + onRefreshReady +}: Props) { + const { t } = useTranslation() + const { relayUrls, loading: relayUrlsLoading } = useProfileRelayUrls(pubkey, isExpanded) + const relaysReady = !relayUrlsLoading + const hasContent = isExpanded && pubkey + + return ( + + + + {t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')} + + + + + {hasContent ? ( + !relaysReady ? ( +
+ +
+ ) : ( +
+ 0 ? relayUrls : undefined} + onRefreshReady={onRefreshReady} + /> +
+ ) + ) : null} +
+
+ ) +} diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 7e68a454..19b3596a 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -49,7 +49,7 @@ import ProfileFeedWithPins from './ProfileFeedWithPins' import ProfileMediaFeed from './ProfileMediaFeed' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import type { TNoteListRef } from '@/components/NoteList' -import ProfileHeaderInteractions from './ProfileHeaderInteractions' +import ProfileInteractionsAccordion from './ProfileInteractionsAccordion' import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' @@ -62,8 +62,6 @@ import { } from '@/components/ScheduleVideoCallDialog' import RawEventDialog from '@/components/NoteOptions/RawEventDialog' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useProfileInteractions } from '@/hooks/useProfileInteractions' -import { useProfileBadges } from '@/hooks/useProfileBadges' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { nip66Service } from '@/services/nip66.service' @@ -287,10 +285,8 @@ export default function Profile({ [profile] ) const isSelf = accountPubkey === profile?.pubkey - const { zaps: profileZaps, reactions: profileReactions, comments: profileComments, loading: profileInteractionsLoading, refresh: refreshProfileInteractions } = - useProfileInteractions(profile?.pubkey, profileEvent) - const { badges: profileBadges, loading: profileBadgesLoading, refresh: refreshProfileBadges } = - useProfileBadges(profile?.pubkey) + const [profileInteractionsExpanded, setProfileInteractionsExpanded] = useState(false) + const profileInteractionsRefreshRef = useRef<(() => void) | null>(null) /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ const allAvailableRelayUrls = useMemo(() => { @@ -354,8 +350,7 @@ export default function Profile({ const m = r as MutableRefObject<{ refresh: () => void } | null> m.current = { refresh: () => { - refreshProfileInteractions() - refreshProfileBadges() + profileInteractionsRefreshRef.current?.() postsFeedRef.current?.refresh() mediaFeedRef.current?.refresh() } @@ -363,7 +358,7 @@ export default function Profile({ return () => { m.current = null } - }, [refreshProfileInteractions, refreshProfileBadges]) + }, []) useEffect(() => { if (!profile?.pubkey) return @@ -427,7 +422,7 @@ export default function Profile({ ? (url) => setOpenCallInviteTo({ pubkey, url }) : undefined } - onProfileInteractionsRefresh={refreshProfileInteractions} + onProfileInteractionsRefresh={() => profileInteractionsRefreshRef.current?.()} /> {isSelf ? ( @@ -454,7 +449,7 @@ export default function Profile({ const evt = await publish(reaction) if (evt) { showSimplePublishSuccess(t('Reaction published')) - refreshProfileInteractions() + profileInteractionsRefreshRef.current?.() } } finally { setSelfReacting(false) @@ -510,7 +505,7 @@ export default function Profile({ parentEvent={profileEvent} open={openSelfReply} setOpen={setOpenSelfReply} - onPublishSuccess={refreshProfileInteractions} + onPublishSuccess={() => profileInteractionsRefreshRef.current?.()} /> )} {!isSelf ? ( @@ -536,18 +531,18 @@ export default function Profile({ {nip05List && nip05List.length > 1 && ( )} -
- +
+
- +
+ { profileInteractionsRefreshRef.current = refresh ?? null }} + /> +
(pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey]) const [copied, setCopied] = useState(false) @@ -17,10 +17,10 @@ export default function PubkeyCopy({ pubkey }: { pubkey: string }) { return (
copyNpub()} > -
{formatNpub(npub, 24)}
+
{formatNpub(npub, showFull ? 99 : 24)}
{copied ? : }
) diff --git a/src/constants.ts b/src/constants.ts index 3655d7fe..6848f8a2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -239,7 +239,8 @@ export const PROFILE_RELAY_URLS = [ 'wss://nos.lol', 'wss://relay.damus.io', 'wss://profiles.nostr1.com', - 'wss://purplepag.es' + 'wss://purplepag.es', + 'wss://thecitadel.nostr1.com' ] // Combined relay URLs for profile fetching - includes both FAST_READ_RELAY_URLS and SEARCHABLE_RELAY_URLS diff --git a/src/hooks/useProfileBadges.tsx b/src/hooks/useProfileBadges.tsx index 2a859375..2f4a4d71 100644 --- a/src/hooks/useProfileBadges.tsx +++ b/src/hooks/useProfileBadges.tsx @@ -1,10 +1,9 @@ -import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants' +import { ExtendedKind } from '@/constants' import { queryService, replaceableEventService } from '@/services/client.service' import { useCallback, useEffect, useRef, useState } from 'react' import { tagNameEquals } from '@/lib/tag' -import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useNostr } from '@/providers/NostrProvider' +import { buildProfileRelayUrls } from '@/lib/profile-relay-urls' export type TProfileBadge = { /** Badge definition coordinate (e.g. "30009:alice:bravery") */ @@ -29,8 +28,8 @@ function parseATag(aTag: string): { kind: number; pubkey: string; d: string } | } /** NIP-58: Fetches profile badges (kind 30008) and resolves badge definitions (kind 30009). */ -export function useProfileBadges(pubkey: string | undefined) { - const { pubkey: accountPubkey } = useNostr() +/** Pass relayUrls to share with other profile fetches. */ +export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[]) { const { blockedRelays } = useFavoriteRelays() const [badges, setBadges] = useState([]) const [loading, setLoading] = useState(false) @@ -46,20 +45,12 @@ export function useProfileBadges(pubkey: string | undefined) { setLoading(true) try { - const relayUrls = await buildComprehensiveRelayList({ - authorPubkey: pubkey, - userPubkey: accountPubkey ?? undefined, - blockedRelays: [...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS], - includeFastReadRelays: true, - includeSearchableRelays: true, - includeProfileFetchRelays: true, - includeLocalRelays: true - }) + const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays)) const events = await queryService.fetchEvents( - relayUrls, + urls, { authors: [pubkey], kinds: [ExtendedKind.PROFILE_BADGES], '#d': ['profile_badges'] }, - undefined + { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } ) const profileBadgesEvent = events.sort((a, b) => b.created_at - a.created_at)[0] @@ -118,7 +109,7 @@ export function useProfileBadges(pubkey: string | undefined) { } finally { if (myFetchId === fetchIdRef.current) setLoading(false) } - }, [pubkey, accountPubkey, blockedRelays]) + }, [pubkey, blockedRelays, relayUrls]) useEffect(() => { fetchBadges() diff --git a/src/hooks/useProfileFollowPacks.tsx b/src/hooks/useProfileFollowPacks.tsx new file mode 100644 index 00000000..26bd8345 --- /dev/null +++ b/src/hooks/useProfileFollowPacks.tsx @@ -0,0 +1,70 @@ +import { ExtendedKind } from '@/constants' +import { queryService } from '@/services/client.service' +import { Event } from 'nostr-tools' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { buildProfileRelayUrls } from '@/lib/profile-relay-urls' + +export type TProfileFollowPack = { + event: Event + title: string +} + +function getPackTitle(event: Event): string { + const titleTag = event.tags.find((tag) => tag[0] === 'title' || tag[0] === 'name') + return titleTag?.[1] || 'Follow Pack' +} + +/** Fetches follow packs (kind 39089) that contain this pubkey in #p tags. */ +export function useProfileFollowPacks( + pubkey: string | undefined, + relayUrls?: string[] +) { + const { blockedRelays } = useFavoriteRelays() + const [packs, setPacks] = useState([]) + const [loading, setLoading] = useState(false) + const fetchIdRef = useRef(0) + + const fetchPacks = useCallback(async () => { + if (!pubkey) { + setPacks([]) + return + } + + const myFetchId = (fetchIdRef.current += 1) + setLoading(true) + + try { + const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays)) + if (urls.length === 0) { + if (myFetchId === fetchIdRef.current) setPacks([]) + return + } + + const events = await queryService.fetchEvents( + urls, + [{ '#p': [pubkey], kinds: [ExtendedKind.FOLLOW_PACK], limit: 50 }], + { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } + ) + + if (myFetchId !== fetchIdRef.current) return + + const result: TProfileFollowPack[] = events.map((evt) => ({ + event: evt, + title: getPackTitle(evt) + })) + setPacks(result) + } catch { + if (myFetchId !== fetchIdRef.current) return + setPacks([]) + } finally { + if (myFetchId === fetchIdRef.current) setLoading(false) + } + }, [pubkey, blockedRelays, relayUrls]) + + useEffect(() => { + fetchPacks() + }, [fetchPacks]) + + return { packs, loading, refresh: fetchPacks } +} diff --git a/src/hooks/useProfileInteractions.tsx b/src/hooks/useProfileInteractions.tsx index 8b098a48..e454bee8 100644 --- a/src/hooks/useProfileInteractions.tsx +++ b/src/hooks/useProfileInteractions.tsx @@ -1,11 +1,11 @@ -import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants' +import { ExtendedKind } from '@/constants' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { queryService } from '@/services/client.service' +import { hexPubkeysEqual } from '@/lib/pubkey' import { Event, Filter, kinds } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' -import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useNostr } from '@/providers/NostrProvider' +import { buildProfileRelayUrls } from '@/lib/profile-relay-urls' export type TProfileZap = { pr: string @@ -15,9 +15,11 @@ export type TProfileZap = { comment?: string } -/** Fetches zaps, reactions (likes), and comments for a profile. */ -export function useProfileInteractions(pubkey: string | undefined, profileEvent: Event | undefined) { - const { pubkey: accountPubkey } = useNostr() +const NOTE_IDS_FOR_REACTIONS = 50 + +/** Fetches zaps, reactions (likes on profile's notes), and comments (on profile's notes). */ +/** Uses profile owner's outboxes + PROFILE_FETCH_RELAY_URLS. Pass relayUrls to share with other profile fetches. */ +export function useProfileInteractions(pubkey: string | undefined, relayUrls?: string[]) { const { blockedRelays } = useFavoriteRelays() const [zaps, setZaps] = useState([]) const [reactions, setReactions] = useState([]) @@ -37,60 +39,110 @@ export function useProfileInteractions(pubkey: string | undefined, profileEvent: setLoading(true) try { - const relayUrls = await buildComprehensiveRelayList({ - authorPubkey: pubkey, - userPubkey: accountPubkey ?? undefined, - blockedRelays: [...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS], - includeFastReadRelays: true, - includeSearchableRelays: true, - includeProfileFetchRelays: true, - includeLocalRelays: true - }) - - const filters: Filter[] = [{ '#p': [pubkey], kinds: [kinds.Zap], limit: 100 }] - if (profileEvent) { - filters.push({ - '#e': [profileEvent.id], - kinds: [kinds.Reaction, ExtendedKind.COMMENT], - limit: 50 - }) - } + const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays)) const collectedZaps: TProfileZap[] = [] - const collectedReactions: Event[] = [] + const reactionsByPubkey = new Map() // one reaction per npub, newest kept const collectedComments: Event[] = [] const seenZaps = new Set() const seenReactions = new Set() + let noteIds: string[] = [] + + // Phase 1: zaps + profile's recent notes (to find reactions/comments on their content) + const phase1Filters: Filter[] = [ + { '#p': [pubkey], kinds: [kinds.Zap], limit: 100 }, + { authors: [pubkey], kinds: [kinds.ShortTextNote], limit: NOTE_IDS_FOR_REACTIONS } + ] - await queryService.fetchEvents(relayUrls, filters, { + const flushZaps = () => { + if (myFetchId !== fetchIdRef.current) return + const sorted = [...collectedZaps].sort((a, b) => b.amount - a.amount) + setZaps(sorted) + } + await queryService.fetchEvents(urls, phase1Filters, { + eoseTimeout: 2000, + globalTimeout: 15000, + firstRelayResultGraceMs: false, onevent: (evt) => { if (evt.kind === kinds.Zap) { const info = getZapInfoFromEvent(evt) - if (!info || info.recipientPubkey !== pubkey || !info.amount || info.amount <= 0) return + if (!info || !hexPubkeysEqual(info.recipientPubkey ?? '', pubkey) || !info.amount || info.amount <= 0) return + const sender = info.senderPubkey ?? evt.pubkey + if (hexPubkeysEqual(sender, pubkey)) return // skip self-zaps (likely tests) if (seenZaps.has(evt.id)) return seenZaps.add(evt.id) collectedZaps.push({ pr: evt.id, - pubkey: info.senderPubkey ?? evt.pubkey, + pubkey: sender, amount: info.amount, created_at: evt.created_at, comment: info.comment }) - } else if (evt.kind === kinds.Reaction || evt.kind === ExtendedKind.COMMENT) { - if (seenReactions.has(evt.id)) return - seenReactions.add(evt.id) - if (evt.kind === kinds.Reaction) { - collectedReactions.push(evt) - } else { - collectedComments.push(evt) - } + flushZaps() // render incrementally as events arrive from slow relays + } else if (evt.kind === kinds.ShortTextNote) { + noteIds.push(evt.id) } } }) + noteIds = [...new Set(noteIds)].slice(0, NOTE_IDS_FOR_REACTIONS) + if (myFetchId !== fetchIdRef.current) return + + const flushReactions = () => { + if (myFetchId !== fetchIdRef.current) return + setReactions(Array.from(reactionsByPubkey.values()).sort((a, b) => b.created_at - a.created_at)) + } + const flushComments = () => { + if (myFetchId !== fetchIdRef.current) return + setComments([...collectedComments].sort((a, b) => b.created_at - a.created_at)) + } + const handleReactionOrComment = (evt: Event) => { + if (hexPubkeysEqual(evt.pubkey, pubkey)) return // skip self-reactions/self-comments (likely tests) + if (seenReactions.has(evt.id)) return + seenReactions.add(evt.id) + if (evt.kind === kinds.Reaction) { + const existing = reactionsByPubkey.get(evt.pubkey) + if (!existing || evt.created_at > existing.created_at) { + reactionsByPubkey.set(evt.pubkey, evt) + } + flushReactions() + } else { + collectedComments.push(evt) + flushComments() + } + } + + const phase2Opts = { + eoseTimeout: 2000, + globalTimeout: 15000, + firstRelayResultGraceMs: false as const, + onevent: (evt: Event) => { + if (evt.kind === kinds.Reaction || evt.kind === ExtendedKind.COMMENT) { + handleReactionOrComment(evt) + } + } + } + + // Phase 2a: reactions and comments on profile's notes (#e) + if (noteIds.length > 0) { + await queryService.fetchEvents(urls, [{ + '#e': noteIds, + kinds: [kinds.Reaction, ExtendedKind.COMMENT], + limit: 50 + }], phase2Opts) + } + + // Phase 2b: comments ON the profile itself (kind 0) - use #a (required), p is optional + const profileAddrs = [`0:${pubkey}:`, `0:${pubkey}:profile`] + await queryService.fetchEvents(urls, [{ + '#a': profileAddrs, + kinds: [ExtendedKind.COMMENT], + limit: 50 + }], phase2Opts) + if (myFetchId !== fetchIdRef.current) return collectedZaps.sort((a, b) => b.amount - a.amount) - collectedReactions.sort((a, b) => b.created_at - a.created_at) + const collectedReactions = Array.from(reactionsByPubkey.values()).sort((a, b) => b.created_at - a.created_at) collectedComments.sort((a, b) => b.created_at - a.created_at) setZaps(collectedZaps) setReactions(collectedReactions) @@ -100,7 +152,7 @@ export function useProfileInteractions(pubkey: string | undefined, profileEvent: } finally { if (myFetchId === fetchIdRef.current) setLoading(false) } - }, [pubkey, profileEvent?.id, accountPubkey, blockedRelays]) + }, [pubkey, blockedRelays, relayUrls]) useEffect(() => { fetchAll() @@ -111,6 +163,6 @@ export function useProfileInteractions(pubkey: string | undefined, profileEvent: /** @deprecated Use useProfileInteractions instead. Returns zaps only for compatibility. */ export function useProfileZaps(pubkey: string | undefined) { - const result = useProfileInteractions(pubkey, undefined) + const result = useProfileInteractions(pubkey) return { zaps: result.zaps, loading: result.loading, refresh: result.refresh } } diff --git a/src/hooks/useProfileRelayUrls.tsx b/src/hooks/useProfileRelayUrls.tsx new file mode 100644 index 00000000..2f4ca085 --- /dev/null +++ b/src/hooks/useProfileRelayUrls.tsx @@ -0,0 +1,33 @@ +import { buildProfileRelayUrls } from '@/lib/profile-relay-urls' +import { useCallback, useEffect, useState } from 'react' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' + +/** Returns profile relay URLs (outboxes + PROFILE_FETCH). Use for sharing relays across profile fetches. */ +export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean) { + const { blockedRelays } = useFavoriteRelays() + const [relayUrls, setRelayUrls] = useState([]) + const [loading, setLoading] = useState(false) + + const fetch = useCallback(async () => { + if (!pubkey || !enabled) { + setRelayUrls([]) + setLoading(false) + return + } + setLoading(true) + try { + const urls = await buildProfileRelayUrls(pubkey, blockedRelays) + setRelayUrls(urls) + } catch { + setRelayUrls([]) + } finally { + setLoading(false) + } + }, [pubkey, enabled, blockedRelays]) + + useEffect(() => { + fetch() + }, [fetch]) + + return { relayUrls, loading, refresh: fetch } +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 328102ef..43b65617 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -479,6 +479,7 @@ export default { Bookmarks: 'Lesezeichen', 'Follow Packs': 'Follow-Packs', 'Follow Pack': 'Follow-Pack', + 'In Follow Packs': 'In Follow-Packs', 'Please log in to follow': 'Zum Folgen bitte anmelden', 'Following All': 'Allen gefolgt', 'Followed {{count}} users': '{{count}} Nutzer:innen gefolgt', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 052d0b72..a0a02e55 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -471,6 +471,7 @@ export default { Bookmarks: 'Bookmarks', 'Follow Packs': 'Follow Packs', 'Follow Pack': 'Follow Pack', + 'In Follow Packs': 'In Follow Packs', 'Please log in to follow': 'Please log in to follow', 'Following All': 'Following All', 'Followed {{count}} users': 'Followed {{count}} users', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 472f0d0f..9975010a 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -442,6 +442,7 @@ export default { Bookmarks: 'Favoris', 'Follow Packs': 'Follow Packs', 'Follow Pack': 'Follow Pack', + 'In Follow Packs': 'Dans les Follow Packs', 'Please log in to follow': 'Please log in to follow', 'Following All': 'Following All', 'Followed {{count}} users': 'Followed {{count}} users', diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 7ce8be41..cb2c29bb 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -440,6 +440,7 @@ export default { Bookmarks: 'Zakładki', 'Follow Packs': 'Follow Packs', 'Follow Pack': 'Follow Pack', + 'In Follow Packs': 'In Follow Packs', 'Please log in to follow': 'Please log in to follow', 'Following All': 'Following All', 'Followed {{count}} users': 'Followed {{count}} users', diff --git a/src/lib/profile-relay-urls.ts b/src/lib/profile-relay-urls.ts new file mode 100644 index 00000000..a42165f8 --- /dev/null +++ b/src/lib/profile-relay-urls.ts @@ -0,0 +1,29 @@ +/** + * Build relay URLs for profile-related fetches (zaps, likes, comments, badges, follow packs). + * Uses profile owner's outboxes + PROFILE_FETCH_RELAY_URLS. + */ + +import { E_TAG_FILTER_BLOCKED_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/constants' +import client from '@/services/client.service' +import { normalizeUrl } from '@/lib/url' + +export async function buildProfileRelayUrls( + pubkey: string, + blockedRelays: string[] = [] +): Promise { + const blocked = new Set( + [...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS].map((u) => (normalizeUrl(u) || u).toLowerCase()) + ) + const addRelay = (url: string | undefined, out: Set) => { + if (!url) return + const n = normalizeUrl(url) || url + if (!n || blocked.has(n.toLowerCase())) return + out.add(n) + } + + const relayUrlsSet = new Set() + const relayList = await client.fetchRelayList(pubkey).catch(() => ({ write: [] as string[], read: [] as string[] })) + ;(relayList?.write ?? []).filter((u): u is string => !!u).forEach((u) => addRelay(u, relayUrlsSet)) + PROFILE_FETCH_RELAY_URLS.forEach((u) => addRelay(u, relayUrlsSet)) + return Array.from(relayUrlsSet) +}