11 changed files with 567 additions and 16 deletions
@ -0,0 +1,179 @@
@@ -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 @@
@@ -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 @@
@@ -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