Browse Source

add profile interaction component

imwald
Silberengel 1 month ago
parent
commit
3472266513
  1. 40
      src/components/Profile/ProfileHeaderInteractions.tsx
  2. 110
      src/components/Profile/ProfileInteractionsAccordion.tsx
  3. 41
      src/components/Profile/index.tsx
  4. 6
      src/components/PubkeyCopy/index.tsx
  5. 3
      src/constants.ts
  6. 25
      src/hooks/useProfileBadges.tsx
  7. 70
      src/hooks/useProfileFollowPacks.tsx
  8. 130
      src/hooks/useProfileInteractions.tsx
  9. 33
      src/hooks/useProfileRelayUrls.tsx
  10. 1
      src/i18n/locales/de.ts
  11. 1
      src/i18n/locales/en.ts
  12. 1
      src/i18n/locales/fr.ts
  13. 1
      src/i18n/locales/pl.ts
  14. 29
      src/lib/profile-relay-urls.ts

40
src/components/Profile/ProfileHeaderInteractions.tsx

@ -8,7 +8,8 @@ import Emoji from '@/components/Emoji'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import type { TProfileZap } from '@/hooks/useProfileInteractions' import type { TProfileZap } from '@/hooks/useProfileInteractions'
import type { TProfileBadge } from '@/hooks/useProfileBadges' 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 { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -18,8 +19,10 @@ type Props = {
reactions: Event[] reactions: Event[]
comments: Event[] comments: Event[]
badges: TProfileBadge[] badges: TProfileBadge[]
followPacks: TProfileFollowPack[]
loading: boolean loading: boolean
badgesLoading: boolean badgesLoading: boolean
followPacksLoading: boolean
} }
const ZAPS_PER_ROW = 4 const ZAPS_PER_ROW = 4
@ -28,6 +31,7 @@ const MAX_ZAPS = ZAPS_PER_ROW * ZAP_ROWS
const BADGES_PER_ROW = 4 const BADGES_PER_ROW = 4
const BADGE_ROWS = 2 const BADGE_ROWS = 2
const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS
const MAX_FOLLOW_PACKS = 8
function ZapBadge({ zap }: { zap: TProfileZap }) { function ZapBadge({ zap }: { zap: TProfileZap }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
@ -85,6 +89,21 @@ function CommentBadge({ event }: { event: Event }) {
) )
} }
function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) {
const { push } = useSecondaryPage()
return (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border hover:bg-muted cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(pack.event.id))}
title={pack.title}
>
<Users className="size-3 shrink-0 text-primary" aria-hidden />
<span className="truncate text-xs text-foreground min-w-0">{pack.title}</span>
</button>
)
}
function BadgeItem({ badge }: { badge: TProfileBadge }) { function BadgeItem({ badge }: { badge: TProfileBadge }) {
const imageUrl = badge.thumb ?? badge.image const imageUrl = badge.thumb ?? badge.image
const label = badge.name ?? badge.a.split(':').pop() ?? '' 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 { t } = useTranslation()
const displayZaps = zaps.slice(0, MAX_ZAPS) const displayZaps = zaps.slice(0, MAX_ZAPS)
const displayBadges = badges.slice(0, MAX_BADGES) const displayBadges = badges.slice(0, MAX_BADGES)
const displayFollowPacks = followPacks.slice(0, MAX_FOLLOW_PACKS)
const Section = ({ title, isEmpty, isLoading, children, skeletonCount = 6 }: { const Section = ({ title, isEmpty, isLoading, children, skeletonCount = 6 }: {
title: string title: string
@ -174,6 +203,13 @@ export default function ProfileHeaderInteractions({ zaps, reactions, comments, b
))} ))}
</div> </div>
</Section> </Section>
<Section title={t('In Follow Packs')} isEmpty={displayFollowPacks.length === 0} isLoading={followPacksLoading} skeletonCount={6}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{displayFollowPacks.map((pack) => (
<FollowPackBadge key={pack.event.id} pack={pack} />
))}
</div>
</Section>
</div> </div>
) )
} }

110
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 (
<ProfileHeaderInteractions
zaps={zaps}
reactions={reactions}
comments={comments}
badges={badges}
followPacks={packs}
loading={loading}
badgesLoading={badgesLoading}
followPacksLoading={followPacksLoading}
/>
)
}
function ProfileInteractionsSkeleton() {
return (
<div className="py-2 space-y-3">
{[6, 4, 4, 8, 6].map((count, i) => (
<div key={i} className="min-w-0">
<Skeleton className="h-3 w-16 mb-1.5" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{Array.from({ length: count }).map((_, j) => (
<Skeleton key={j} className="h-8 rounded-md min-w-0" />
))}
</div>
</div>
))}
</div>
)
}
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 (
<Collapsible open={isExpanded} onOpenChange={onExpandedChange} className="min-w-0">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/80 bg-muted/15 px-3 py-2 text-left hover:bg-muted/25 min-w-0">
<span className="text-sm font-medium truncate">
{t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}
</span>
<ChevronDown
className={cn('size-4 shrink-0 text-muted-foreground transition-transform', isExpanded && 'rotate-180')}
/>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
{hasContent ? (
!relaysReady ? (
<div className="pt-2">
<ProfileInteractionsSkeleton />
</div>
) : (
<div className="pt-2">
<ProfileInteractionsContent
pubkey={pubkey}
relayUrls={relayUrls.length > 0 ? relayUrls : undefined}
onRefreshReady={onRefreshReady}
/>
</div>
)
) : null}
</CollapsibleContent>
</Collapsible>
)
}

41
src/components/Profile/index.tsx

@ -49,7 +49,7 @@ import ProfileFeedWithPins from './ProfileFeedWithPins'
import ProfileMediaFeed from './ProfileMediaFeed' import ProfileMediaFeed from './ProfileMediaFeed'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import ProfileHeaderInteractions from './ProfileHeaderInteractions' import ProfileInteractionsAccordion from './ProfileInteractionsAccordion'
import SmartFollowings from './SmartFollowings' import SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink' import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays' import SmartRelays from './SmartRelays'
@ -62,8 +62,6 @@ import {
} from '@/components/ScheduleVideoCallDialog' } from '@/components/ScheduleVideoCallDialog'
import RawEventDialog from '@/components/NoteOptions/RawEventDialog' import RawEventDialog from '@/components/NoteOptions/RawEventDialog'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useProfileInteractions } from '@/hooks/useProfileInteractions'
import { useProfileBadges } from '@/hooks/useProfileBadges'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service' import { nip66Service } from '@/services/nip66.service'
@ -287,10 +285,8 @@ export default function Profile({
[profile] [profile]
) )
const isSelf = accountPubkey === profile?.pubkey const isSelf = accountPubkey === profile?.pubkey
const { zaps: profileZaps, reactions: profileReactions, comments: profileComments, loading: profileInteractionsLoading, refresh: refreshProfileInteractions } = const [profileInteractionsExpanded, setProfileInteractionsExpanded] = useState(false)
useProfileInteractions(profile?.pubkey, profileEvent) const profileInteractionsRefreshRef = useRef<(() => void) | null>(null)
const { badges: profileBadges, loading: profileBadgesLoading, refresh: refreshProfileBadges } =
useProfileBadges(profile?.pubkey)
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => { const allAvailableRelayUrls = useMemo(() => {
@ -354,8 +350,7 @@ export default function Profile({
const m = r as MutableRefObject<{ refresh: () => void } | null> const m = r as MutableRefObject<{ refresh: () => void } | null>
m.current = { m.current = {
refresh: () => { refresh: () => {
refreshProfileInteractions() profileInteractionsRefreshRef.current?.()
refreshProfileBadges()
postsFeedRef.current?.refresh() postsFeedRef.current?.refresh()
mediaFeedRef.current?.refresh() mediaFeedRef.current?.refresh()
} }
@ -363,7 +358,7 @@ export default function Profile({
return () => { return () => {
m.current = null m.current = null
} }
}, [refreshProfileInteractions, refreshProfileBadges]) }, [])
useEffect(() => { useEffect(() => {
if (!profile?.pubkey) return if (!profile?.pubkey) return
@ -427,7 +422,7 @@ export default function Profile({
? (url) => setOpenCallInviteTo({ pubkey, url }) ? (url) => setOpenCallInviteTo({ pubkey, url })
: undefined : undefined
} }
onProfileInteractionsRefresh={refreshProfileInteractions} onProfileInteractionsRefresh={() => profileInteractionsRefreshRef.current?.()}
/> />
{isSelf ? ( {isSelf ? (
<DropdownMenu> <DropdownMenu>
@ -454,7 +449,7 @@ export default function Profile({
const evt = await publish(reaction) const evt = await publish(reaction)
if (evt) { if (evt) {
showSimplePublishSuccess(t('Reaction published')) showSimplePublishSuccess(t('Reaction published'))
refreshProfileInteractions() profileInteractionsRefreshRef.current?.()
} }
} finally { } finally {
setSelfReacting(false) setSelfReacting(false)
@ -510,7 +505,7 @@ export default function Profile({
parentEvent={profileEvent} parentEvent={profileEvent}
open={openSelfReply} open={openSelfReply}
setOpen={setOpenSelfReply} setOpen={setOpenSelfReply}
onPublishSuccess={refreshProfileInteractions} onPublishSuccess={() => profileInteractionsRefreshRef.current?.()}
/> />
)} )}
{!isSelf ? ( {!isSelf ? (
@ -536,18 +531,18 @@ export default function Profile({
{nip05List && nip05List.length > 1 && ( {nip05List && nip05List.length > 1 && (
<Nip05List nip05List={nip05List.slice(1)} pubkey={pubkey} /> <Nip05List nip05List={nip05List.slice(1)} pubkey={pubkey} />
)} )}
<div className="flex gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1 min-w-0">
<PubkeyCopy pubkey={pubkey} /> <PubkeyCopy pubkey={pubkey} showFull />
<NpubQrCode pubkey={pubkey} /> <NpubQrCode pubkey={pubkey} />
</div> </div>
<ProfileHeaderInteractions <div className="mt-4 pt-2">
zaps={profileZaps} <ProfileInteractionsAccordion
reactions={profileReactions} pubkey={pubkey}
comments={profileComments} isExpanded={profileInteractionsExpanded}
badges={profileBadges} onExpandedChange={setProfileInteractionsExpanded}
loading={profileInteractionsLoading} onRefreshReady={(refresh) => { profileInteractionsRefreshRef.current = refresh ?? null }}
badgesLoading={profileBadgesLoading} />
/> </div>
<Collapsible> <Collapsible>
<ProfileAbout <ProfileAbout
about={about} about={about}

6
src/components/PubkeyCopy/index.tsx

@ -3,7 +3,7 @@ import { Check, Copy } from 'lucide-react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
export default function PubkeyCopy({ pubkey }: { pubkey: string }) { export default function PubkeyCopy({ pubkey, showFull }: { pubkey: string; showFull?: boolean }) {
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey]) const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@ -17,10 +17,10 @@ export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
return ( return (
<div <div
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full clickable" className={`flex gap-2 text-sm text-muted-foreground items-center bg-muted px-2 rounded-full clickable ${showFull ? 'max-w-full break-all' : 'w-fit'}`}
onClick={() => copyNpub()} onClick={() => copyNpub()}
> >
<div>{formatNpub(npub, 24)}</div> <div className={showFull ? 'break-all min-w-0' : ''}>{formatNpub(npub, showFull ? 99 : 24)}</div>
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? <Check size={14} /> : <Copy size={14} />}
</div> </div>
) )

3
src/constants.ts

@ -239,7 +239,8 @@ export const PROFILE_RELAY_URLS = [
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://profiles.nostr1.com', '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 // Combined relay URLs for profile fetching - includes both FAST_READ_RELAY_URLS and SEARCHABLE_RELAY_URLS

25
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 { queryService, replaceableEventService } from '@/services/client.service'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { buildProfileRelayUrls } from '@/lib/profile-relay-urls'
export type TProfileBadge = { export type TProfileBadge = {
/** Badge definition coordinate (e.g. "30009:alice:bravery") */ /** 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). */ /** NIP-58: Fetches profile badges (kind 30008) and resolves badge definitions (kind 30009). */
export function useProfileBadges(pubkey: string | undefined) { /** Pass relayUrls to share with other profile fetches. */
const { pubkey: accountPubkey } = useNostr() export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[]) {
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const [badges, setBadges] = useState<TProfileBadge[]>([]) const [badges, setBadges] = useState<TProfileBadge[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -46,20 +45,12 @@ export function useProfileBadges(pubkey: string | undefined) {
setLoading(true) setLoading(true)
try { try {
const relayUrls = await buildComprehensiveRelayList({ const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays))
authorPubkey: pubkey,
userPubkey: accountPubkey ?? undefined,
blockedRelays: [...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS],
includeFastReadRelays: true,
includeSearchableRelays: true,
includeProfileFetchRelays: true,
includeLocalRelays: true
})
const events = await queryService.fetchEvents( const events = await queryService.fetchEvents(
relayUrls, urls,
{ authors: [pubkey], kinds: [ExtendedKind.PROFILE_BADGES], '#d': ['profile_badges'] }, { 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] const profileBadgesEvent = events.sort((a, b) => b.created_at - a.created_at)[0]
@ -118,7 +109,7 @@ export function useProfileBadges(pubkey: string | undefined) {
} finally { } finally {
if (myFetchId === fetchIdRef.current) setLoading(false) if (myFetchId === fetchIdRef.current) setLoading(false)
} }
}, [pubkey, accountPubkey, blockedRelays]) }, [pubkey, blockedRelays, relayUrls])
useEffect(() => { useEffect(() => {
fetchBadges() fetchBadges()

70
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<TProfileFollowPack[]>([])
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 }
}

130
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 { getZapInfoFromEvent } from '@/lib/event-metadata'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { Event, Filter, kinds } from 'nostr-tools' import { Event, Filter, kinds } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { buildProfileRelayUrls } from '@/lib/profile-relay-urls'
export type TProfileZap = { export type TProfileZap = {
pr: string pr: string
@ -15,9 +15,11 @@ export type TProfileZap = {
comment?: string comment?: string
} }
/** Fetches zaps, reactions (likes), and comments for a profile. */ const NOTE_IDS_FOR_REACTIONS = 50
export function useProfileInteractions(pubkey: string | undefined, profileEvent: Event | undefined) {
const { pubkey: accountPubkey } = useNostr() /** 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 { blockedRelays } = useFavoriteRelays()
const [zaps, setZaps] = useState<TProfileZap[]>([]) const [zaps, setZaps] = useState<TProfileZap[]>([])
const [reactions, setReactions] = useState<Event[]>([]) const [reactions, setReactions] = useState<Event[]>([])
@ -37,60 +39,110 @@ export function useProfileInteractions(pubkey: string | undefined, profileEvent:
setLoading(true) setLoading(true)
try { try {
const relayUrls = await buildComprehensiveRelayList({ const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays))
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 collectedZaps: TProfileZap[] = [] const collectedZaps: TProfileZap[] = []
const collectedReactions: Event[] = [] const reactionsByPubkey = new Map<string, Event>() // one reaction per npub, newest kept
const collectedComments: Event[] = [] const collectedComments: Event[] = []
const seenZaps = new Set<string>() const seenZaps = new Set<string>()
const seenReactions = new Set<string>() const seenReactions = new Set<string>()
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) => { onevent: (evt) => {
if (evt.kind === kinds.Zap) { if (evt.kind === kinds.Zap) {
const info = getZapInfoFromEvent(evt) 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 if (seenZaps.has(evt.id)) return
seenZaps.add(evt.id) seenZaps.add(evt.id)
collectedZaps.push({ collectedZaps.push({
pr: evt.id, pr: evt.id,
pubkey: info.senderPubkey ?? evt.pubkey, pubkey: sender,
amount: info.amount, amount: info.amount,
created_at: evt.created_at, created_at: evt.created_at,
comment: info.comment comment: info.comment
}) })
} else if (evt.kind === kinds.Reaction || evt.kind === ExtendedKind.COMMENT) { flushZaps() // render incrementally as events arrive from slow relays
if (seenReactions.has(evt.id)) return } else if (evt.kind === kinds.ShortTextNote) {
seenReactions.add(evt.id) noteIds.push(evt.id)
if (evt.kind === kinds.Reaction) {
collectedReactions.push(evt)
} else {
collectedComments.push(evt)
}
} }
} }
}) })
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 if (myFetchId !== fetchIdRef.current) return
collectedZaps.sort((a, b) => b.amount - a.amount) 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) collectedComments.sort((a, b) => b.created_at - a.created_at)
setZaps(collectedZaps) setZaps(collectedZaps)
setReactions(collectedReactions) setReactions(collectedReactions)
@ -100,7 +152,7 @@ export function useProfileInteractions(pubkey: string | undefined, profileEvent:
} finally { } finally {
if (myFetchId === fetchIdRef.current) setLoading(false) if (myFetchId === fetchIdRef.current) setLoading(false)
} }
}, [pubkey, profileEvent?.id, accountPubkey, blockedRelays]) }, [pubkey, blockedRelays, relayUrls])
useEffect(() => { useEffect(() => {
fetchAll() fetchAll()
@ -111,6 +163,6 @@ export function useProfileInteractions(pubkey: string | undefined, profileEvent:
/** @deprecated Use useProfileInteractions instead. Returns zaps only for compatibility. */ /** @deprecated Use useProfileInteractions instead. Returns zaps only for compatibility. */
export function useProfileZaps(pubkey: string | undefined) { 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 } return { zaps: result.zaps, loading: result.loading, refresh: result.refresh }
} }

33
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<string[]>([])
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 }
}

1
src/i18n/locales/de.ts

@ -479,6 +479,7 @@ export default {
Bookmarks: 'Lesezeichen', Bookmarks: 'Lesezeichen',
'Follow Packs': 'Follow-Packs', 'Follow Packs': 'Follow-Packs',
'Follow Pack': 'Follow-Pack', 'Follow Pack': 'Follow-Pack',
'In Follow Packs': 'In Follow-Packs',
'Please log in to follow': 'Zum Folgen bitte anmelden', 'Please log in to follow': 'Zum Folgen bitte anmelden',
'Following All': 'Allen gefolgt', 'Following All': 'Allen gefolgt',
'Followed {{count}} users': '{{count}} Nutzer:innen gefolgt', 'Followed {{count}} users': '{{count}} Nutzer:innen gefolgt',

1
src/i18n/locales/en.ts

@ -471,6 +471,7 @@ export default {
Bookmarks: 'Bookmarks', Bookmarks: 'Bookmarks',
'Follow Packs': 'Follow Packs', 'Follow Packs': 'Follow Packs',
'Follow Pack': 'Follow Pack', 'Follow Pack': 'Follow Pack',
'In Follow Packs': 'In Follow Packs',
'Please log in to follow': 'Please log in to follow', 'Please log in to follow': 'Please log in to follow',
'Following All': 'Following All', 'Following All': 'Following All',
'Followed {{count}} users': 'Followed {{count}} users', 'Followed {{count}} users': 'Followed {{count}} users',

1
src/i18n/locales/fr.ts

@ -442,6 +442,7 @@ export default {
Bookmarks: 'Favoris', Bookmarks: 'Favoris',
'Follow Packs': 'Follow Packs', 'Follow Packs': 'Follow Packs',
'Follow Pack': 'Follow Pack', 'Follow Pack': 'Follow Pack',
'In Follow Packs': 'Dans les Follow Packs',
'Please log in to follow': 'Please log in to follow', 'Please log in to follow': 'Please log in to follow',
'Following All': 'Following All', 'Following All': 'Following All',
'Followed {{count}} users': 'Followed {{count}} users', 'Followed {{count}} users': 'Followed {{count}} users',

1
src/i18n/locales/pl.ts

@ -440,6 +440,7 @@ export default {
Bookmarks: 'Zakładki', Bookmarks: 'Zakładki',
'Follow Packs': 'Follow Packs', 'Follow Packs': 'Follow Packs',
'Follow Pack': 'Follow Pack', 'Follow Pack': 'Follow Pack',
'In Follow Packs': 'In Follow Packs',
'Please log in to follow': 'Please log in to follow', 'Please log in to follow': 'Please log in to follow',
'Following All': 'Following All', 'Following All': 'Following All',
'Followed {{count}} users': 'Followed {{count}} users', 'Followed {{count}} users': 'Followed {{count}} users',

29
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<string[]> {
const blocked = new Set(
[...blockedRelays, ...E_TAG_FILTER_BLOCKED_RELAY_URLS].map((u) => (normalizeUrl(u) || u).toLowerCase())
)
const addRelay = (url: string | undefined, out: Set<string>) => {
if (!url) return
const n = normalizeUrl(url) || url
if (!n || blocked.has(n.toLowerCase())) return
out.add(n)
}
const relayUrlsSet = new Set<string>()
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)
}
Loading…
Cancel
Save