11 changed files with 567 additions and 16 deletions
@ -0,0 +1,179 @@ |
|||||||
|
import Content from '@/components/Content' |
||||||
|
import UserAvatar from '@/components/UserAvatar' |
||||||
|
import Username from '@/components/Username' |
||||||
|
import { formatAmount } from '@/lib/lightning' |
||||||
|
import { toNote, toProfile } from '@/lib/link' |
||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
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 { Skeleton } from '@/components/ui/skeleton' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
type Props = { |
||||||
|
zaps: TProfileZap[] |
||||||
|
reactions: Event[] |
||||||
|
comments: Event[] |
||||||
|
badges: TProfileBadge[] |
||||||
|
loading: boolean |
||||||
|
badgesLoading: boolean |
||||||
|
} |
||||||
|
|
||||||
|
const ZAPS_PER_ROW = 4 |
||||||
|
const ZAP_ROWS = 3 |
||||||
|
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 |
||||||
|
|
||||||
|
function ZapBadge({ zap }: { zap: TProfileZap }) { |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
return ( |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border border-yellow-400/40 hover:bg-yellow-400/10 cursor-pointer text-left min-w-0 w-full" |
||||||
|
onClick={() => push(toProfile(zap.pubkey))} |
||||||
|
> |
||||||
|
<UserAvatar userId={zap.pubkey} size="tiny" className="shrink-0" /> |
||||||
|
<Zap className="size-3 shrink-0 text-yellow-500 fill-yellow-500" strokeWidth={2} aria-hidden /> |
||||||
|
<span className="font-semibold tabular-nums text-xs text-foreground truncate">{formatAmount(zap.amount)}</span> |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function ReactionBadge({ event }: { event: Event }) { |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags) |
||||||
|
const displayContent = event.content.trim() || (emojiInfos[0] ? emojiInfos[0].shortcode : '+') |
||||||
|
const isPlus = displayContent === '+' |
||||||
|
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 min-w-0 w-full" |
||||||
|
onClick={() => push(toProfile(event.pubkey))} |
||||||
|
> |
||||||
|
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" /> |
||||||
|
{isPlus ? ( |
||||||
|
<ThumbsUp className="size-3 shrink-0 text-primary" aria-hidden /> |
||||||
|
) : typeof displayContent === 'string' && !displayContent.startsWith(':') ? ( |
||||||
|
<span className="text-xs shrink-0">{displayContent}</span> |
||||||
|
) : ( |
||||||
|
<Emoji emoji={emojiInfos[0] ?? displayContent} classNames={{ img: 'size-3' }} /> |
||||||
|
)} |
||||||
|
<Username userId={event.pubkey} className="truncate text-xs text-muted-foreground min-w-0" skeletonClassName="h-3" /> |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function CommentBadge({ event }: { event: Event }) { |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
return ( |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border cursor-pointer text-left min-w-0 w-full" |
||||||
|
onClick={() => push(toNote(event.id))} |
||||||
|
> |
||||||
|
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" /> |
||||||
|
<MessageCircle className="size-3 shrink-0 text-primary" aria-hidden /> |
||||||
|
<span className="truncate text-xs text-muted-foreground min-w-0"> |
||||||
|
<Content content={event.content} className="text-xs [&_p]:text-xs [&_p]:m-0 [&_p]:inline" /> |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function BadgeItem({ badge }: { badge: TProfileBadge }) { |
||||||
|
const imageUrl = badge.thumb ?? badge.image |
||||||
|
const label = badge.name ?? badge.a.split(':').pop() ?? '' |
||||||
|
if (!imageUrl) { |
||||||
|
return ( |
||||||
|
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted text-xs text-muted-foreground" title={label}> |
||||||
|
{label.slice(0, 2)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
return ( |
||||||
|
<div className="relative size-12 shrink-0"> |
||||||
|
<img |
||||||
|
src={imageUrl} |
||||||
|
alt={label} |
||||||
|
title={label} |
||||||
|
className="size-12 rounded-lg border object-cover bg-muted" |
||||||
|
loading="lazy" |
||||||
|
onError={(e) => { |
||||||
|
e.currentTarget.style.display = 'none' |
||||||
|
const fallback = e.currentTarget.nextElementSibling as HTMLElement |
||||||
|
if (fallback) fallback.classList.remove('hidden') |
||||||
|
}} |
||||||
|
/> |
||||||
|
<div className="hidden absolute inset-0 flex items-center justify-center rounded-lg border bg-muted text-xs text-muted-foreground" title={label}> |
||||||
|
{label.slice(0, 2)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default function ProfileHeaderInteractions({ zaps, reactions, comments, badges, loading, badgesLoading }: Props) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const displayZaps = zaps.slice(0, MAX_ZAPS) |
||||||
|
const displayBadges = badges.slice(0, MAX_BADGES) |
||||||
|
|
||||||
|
const Section = ({ title, isEmpty, isLoading, children, skeletonCount = 6 }: { |
||||||
|
title: string |
||||||
|
isEmpty: boolean |
||||||
|
isLoading: boolean |
||||||
|
children: React.ReactNode |
||||||
|
skeletonCount?: number |
||||||
|
}) => ( |
||||||
|
<div className="min-w-0"> |
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1.5">{title}</div> |
||||||
|
{isLoading && isEmpty ? ( |
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> |
||||||
|
{Array.from({ length: skeletonCount }).map((_, i) => ( |
||||||
|
<Skeleton key={i} className="h-8 rounded-md min-w-0" /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) : isEmpty ? ( |
||||||
|
<div className="text-xs text-muted-foreground py-1">{t('None')}</div> |
||||||
|
) : ( |
||||||
|
children |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="py-2 space-y-3 w-full min-w-0 overflow-visible"> |
||||||
|
<Section title={t('Zaps')} isEmpty={displayZaps.length === 0} isLoading={loading}> |
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 grid-rows-3 gap-1.5"> |
||||||
|
{displayZaps.map((item) => ( |
||||||
|
<ZapBadge key={`zap-${item.pr}`} zap={item} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</Section> |
||||||
|
<Section title={t('Likes')} isEmpty={reactions.length === 0} isLoading={loading}> |
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> |
||||||
|
{reactions.map((item) => ( |
||||||
|
<ReactionBadge key={`reaction-${item.id}`} event={item} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</Section> |
||||||
|
<Section title={t('Comments')} isEmpty={comments.length === 0} isLoading={loading}> |
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> |
||||||
|
{comments.map((item) => ( |
||||||
|
<CommentBadge key={`comment-${item.id}`} event={item} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</Section> |
||||||
|
<Section title={t('Badges')} isEmpty={displayBadges.length === 0} isLoading={badgesLoading} skeletonCount={8}> |
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 grid-rows-2 gap-1.5"> |
||||||
|
{displayBadges.map((badge) => ( |
||||||
|
<BadgeItem key={`${badge.a}-${badge.awardId}`} badge={badge} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</Section> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,128 @@ |
|||||||
|
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, 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' |
||||||
|
|
||||||
|
export type TProfileBadge = { |
||||||
|
/** Badge definition coordinate (e.g. "30009:alice:bravery") */ |
||||||
|
a: string |
||||||
|
/** Badge award event id */ |
||||||
|
awardId: string |
||||||
|
/** Human-readable name from definition */ |
||||||
|
name?: string |
||||||
|
/** High-res image URL */ |
||||||
|
image?: string |
||||||
|
/** Thumbnail URL (prefer thumb over image for grid display) */ |
||||||
|
thumb?: string |
||||||
|
} |
||||||
|
|
||||||
|
/** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */ |
||||||
|
function parseATag(aTag: string): { kind: number; pubkey: string; d: string } | null { |
||||||
|
const parts = aTag.split(':') |
||||||
|
if (parts.length < 3) return null |
||||||
|
const kind = parseInt(parts[0], 10) |
||||||
|
if (isNaN(kind)) return null |
||||||
|
return { kind, pubkey: parts[1], d: parts[2] } |
||||||
|
} |
||||||
|
|
||||||
|
/** NIP-58: Fetches profile badges (kind 30008) and resolves badge definitions (kind 30009). */ |
||||||
|
export function useProfileBadges(pubkey: string | undefined) { |
||||||
|
const { pubkey: accountPubkey } = useNostr() |
||||||
|
const { blockedRelays } = useFavoriteRelays() |
||||||
|
const [badges, setBadges] = useState<TProfileBadge[]>([]) |
||||||
|
const [loading, setLoading] = useState(false) |
||||||
|
const fetchIdRef = useRef(0) |
||||||
|
|
||||||
|
const fetchBadges = useCallback(async () => { |
||||||
|
if (!pubkey) { |
||||||
|
setBadges([]) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const myFetchId = (fetchIdRef.current += 1) |
||||||
|
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 events = await queryService.fetchEvents( |
||||||
|
relayUrls, |
||||||
|
{ authors: [pubkey], kinds: [ExtendedKind.PROFILE_BADGES], '#d': ['profile_badges'] }, |
||||||
|
undefined |
||||||
|
) |
||||||
|
const profileBadgesEvent = events.sort((a, b) => b.created_at - a.created_at)[0] |
||||||
|
|
||||||
|
if (!profileBadgesEvent || myFetchId !== fetchIdRef.current) { |
||||||
|
if (myFetchId === fetchIdRef.current) setBadges([]) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const tags = profileBadgesEvent.tags |
||||||
|
const pairs: { a: string; e: string }[] = [] |
||||||
|
for (let i = 0; i < tags.length - 1; i++) { |
||||||
|
const [tagNameA, aVal] = tags[i] |
||||||
|
const [tagNameE, eVal] = tags[i + 1] |
||||||
|
if (tagNameA === 'a' && tagNameE === 'e' && aVal && eVal && /^[a-f0-9]{64}$/i.test(eVal)) { |
||||||
|
pairs.push({ a: aVal, e: eVal }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (pairs.length === 0) { |
||||||
|
setBadges([]) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const result: TProfileBadge[] = [] |
||||||
|
for (const { a, e } of pairs) { |
||||||
|
const parsed = parseATag(a) |
||||||
|
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) { |
||||||
|
result.push({ a, awardId: e }) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
const defEvent = await replaceableEventService.fetchReplaceableEvent( |
||||||
|
parsed.pubkey, |
||||||
|
parsed.kind, |
||||||
|
parsed.d |
||||||
|
) |
||||||
|
|
||||||
|
const name = defEvent?.tags.find(tagNameEquals('name'))?.[1] |
||||||
|
const image = defEvent?.tags.find(tagNameEquals('image'))?.[1] |
||||||
|
const thumb = defEvent?.tags.find(tagNameEquals('thumb'))?.[1] |
||||||
|
|
||||||
|
result.push({ |
||||||
|
a, |
||||||
|
awardId: e, |
||||||
|
name: name ?? parsed.d, |
||||||
|
image, |
||||||
|
thumb: thumb ?? image |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
if (myFetchId !== fetchIdRef.current) return |
||||||
|
setBadges(result) |
||||||
|
} catch { |
||||||
|
if (myFetchId !== fetchIdRef.current) return |
||||||
|
setBadges([]) |
||||||
|
} finally { |
||||||
|
if (myFetchId === fetchIdRef.current) setLoading(false) |
||||||
|
} |
||||||
|
}, [pubkey, accountPubkey, blockedRelays]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
fetchBadges() |
||||||
|
}, [fetchBadges]) |
||||||
|
|
||||||
|
return { badges, loading, refresh: fetchBadges } |
||||||
|
} |
||||||
@ -0,0 +1,116 @@ |
|||||||
|
import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind } from '@/constants' |
||||||
|
import { getZapInfoFromEvent } from '@/lib/event-metadata' |
||||||
|
import { queryService } from '@/services/client.service' |
||||||
|
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' |
||||||
|
|
||||||
|
export type TProfileZap = { |
||||||
|
pr: string |
||||||
|
pubkey: string |
||||||
|
amount: number |
||||||
|
created_at: number |
||||||
|
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 { blockedRelays } = useFavoriteRelays() |
||||||
|
const [zaps, setZaps] = useState<TProfileZap[]>([]) |
||||||
|
const [reactions, setReactions] = useState<Event[]>([]) |
||||||
|
const [comments, setComments] = useState<Event[]>([]) |
||||||
|
const [loading, setLoading] = useState(false) |
||||||
|
const fetchIdRef = useRef(0) |
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => { |
||||||
|
if (!pubkey) { |
||||||
|
setZaps([]) |
||||||
|
setReactions([]) |
||||||
|
setComments([]) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const myFetchId = (fetchIdRef.current += 1) |
||||||
|
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 collectedZaps: TProfileZap[] = [] |
||||||
|
const collectedReactions: Event[] = [] |
||||||
|
const collectedComments: Event[] = [] |
||||||
|
const seenZaps = new Set<string>() |
||||||
|
const seenReactions = new Set<string>() |
||||||
|
|
||||||
|
await queryService.fetchEvents(relayUrls, filters, { |
||||||
|
onevent: (evt) => { |
||||||
|
if (evt.kind === kinds.Zap) { |
||||||
|
const info = getZapInfoFromEvent(evt) |
||||||
|
if (!info || info.recipientPubkey !== pubkey || !info.amount || info.amount <= 0) return |
||||||
|
if (seenZaps.has(evt.id)) return |
||||||
|
seenZaps.add(evt.id) |
||||||
|
collectedZaps.push({ |
||||||
|
pr: evt.id, |
||||||
|
pubkey: info.senderPubkey ?? evt.pubkey, |
||||||
|
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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
if (myFetchId !== fetchIdRef.current) return |
||||||
|
collectedZaps.sort((a, b) => b.amount - a.amount) |
||||||
|
collectedReactions.sort((a, b) => b.created_at - a.created_at) |
||||||
|
collectedComments.sort((a, b) => b.created_at - a.created_at) |
||||||
|
setZaps(collectedZaps) |
||||||
|
setReactions(collectedReactions) |
||||||
|
setComments(collectedComments) |
||||||
|
} catch { |
||||||
|
if (myFetchId !== fetchIdRef.current) return |
||||||
|
} finally { |
||||||
|
if (myFetchId === fetchIdRef.current) setLoading(false) |
||||||
|
} |
||||||
|
}, [pubkey, profileEvent?.id, accountPubkey, blockedRelays]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
fetchAll() |
||||||
|
}, [fetchAll]) |
||||||
|
|
||||||
|
return { zaps, reactions, comments, loading, refresh: fetchAll } |
||||||
|
} |
||||||
|
|
||||||
|
/** @deprecated Use useProfileInteractions instead. Returns zaps only for compatibility. */ |
||||||
|
export function useProfileZaps(pubkey: string | undefined) { |
||||||
|
const result = useProfileInteractions(pubkey, undefined) |
||||||
|
return { zaps: result.zaps, loading: result.loading, refresh: result.refresh } |
||||||
|
} |
||||||
Loading…
Reference in new issue