diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx
index 1f6c679d..45d90ae0 100644
--- a/src/components/PostEditor/PostContent.tsx
+++ b/src/components/PostEditor/PostContent.tsx
@@ -92,7 +92,8 @@ export default function PostContent({
close,
openFrom,
initialHighlightData,
- initialPublicMessageTo
+ initialPublicMessageTo,
+ onPublishSuccess
}: {
defaultContent?: string
parentEvent?: Event
@@ -101,6 +102,8 @@ export default function PostContent({
initialHighlightData?: HighlightData
/** When set, opens in public message mode with this pubkey in the mention list. */
initialPublicMessageTo?: string
+ /** Called after a reply/post is successfully published, before closing. */
+ onPublishSuccess?: () => void
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
@@ -927,6 +930,7 @@ export default function PostContent({
mergePublishedReplyIntoThread(cleanEvent, relayStatuses)
}
+ onPublishSuccess?.()
close()
} catch (error) {
// AggregateError = "Failed to publish to any relay" is already logged in NostrProvider with relayStatuses; avoid duplicate noise
@@ -968,6 +972,7 @@ export default function PostContent({
}
postEditorCache.clearPostCache({ defaultContent, parentEvent })
if (draftEvent) deleteDraftEventCache(draftEvent)
+ onPublishSuccess?.()
close()
}
} else {
diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx
index 571df99f..8445eb2a 100644
--- a/src/components/PostEditor/index.tsx
+++ b/src/components/PostEditor/index.tsx
@@ -27,7 +27,8 @@ export default function PostEditor({
setOpen,
openFrom,
initialHighlightData,
- initialPublicMessageTo
+ initialPublicMessageTo,
+ onPublishSuccess
}: {
defaultContent?: string
parentEvent?: Event
@@ -37,6 +38,8 @@ export default function PostEditor({
initialHighlightData?: import('./HighlightEditor').HighlightData
/** When set, opens in public message mode with this pubkey in the mention list. */
initialPublicMessageTo?: string
+ /** Called after a reply/post is successfully published, before closing. */
+ onPublishSuccess?: () => void
}) {
const { isSmallScreen } = useScreenSize()
@@ -58,9 +61,10 @@ export default function PostEditor({
openFrom={openFrom}
initialHighlightData={initialHighlightData}
initialPublicMessageTo={initialPublicMessageTo}
+ onPublishSuccess={onPublishSuccess}
/>
)
- }, [effectiveDefaultContent, parentEvent, openFrom, setOpen, initialHighlightData, initialPublicMessageTo])
+ }, [effectiveDefaultContent, parentEvent, openFrom, setOpen, initialHighlightData, initialPublicMessageTo, onPublishSuccess])
if (isSmallScreen) {
return (
diff --git a/src/components/Profile/ProfileHeaderInteractions.tsx b/src/components/Profile/ProfileHeaderInteractions.tsx
new file mode 100644
index 00000000..0c1a3085
--- /dev/null
+++ b/src/components/Profile/ProfileHeaderInteractions.tsx
@@ -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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+function CommentBadge({ event }: { event: Event }) {
+ const { push } = useSecondaryPage()
+ return (
+
+ )
+}
+
+function BadgeItem({ badge }: { badge: TProfileBadge }) {
+ const imageUrl = badge.thumb ?? badge.image
+ const label = badge.name ?? badge.a.split(':').pop() ?? ''
+ if (!imageUrl) {
+ return (
+
+ {label.slice(0, 2)}
+
+ )
+ }
+ return (
+
+

{
+ e.currentTarget.style.display = 'none'
+ const fallback = e.currentTarget.nextElementSibling as HTMLElement
+ if (fallback) fallback.classList.remove('hidden')
+ }}
+ />
+
+ {label.slice(0, 2)}
+
+
+ )
+}
+
+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
+ }) => (
+
+
{title}
+ {isLoading && isEmpty ? (
+
+ {Array.from({ length: skeletonCount }).map((_, i) => (
+
+ ))}
+
+ ) : isEmpty ? (
+
{t('None')}
+ ) : (
+ children
+ )}
+
+ )
+
+ return (
+
+
+
+ {displayZaps.map((item) => (
+
+ ))}
+
+
+
+
+ {reactions.map((item) => (
+
+ ))}
+
+
+
+
+ {comments.map((item) => (
+
+ ))}
+
+
+
+
+ {displayBadges.map((badge) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/Profile/SmartFollowings.tsx b/src/components/Profile/SmartFollowings.tsx
index d255c19c..7f47aa3d 100644
--- a/src/components/Profile/SmartFollowings.tsx
+++ b/src/components/Profile/SmartFollowings.tsx
@@ -1,7 +1,7 @@
import { useFetchFollowings } from '@/hooks'
import { toFollowingList } from '@/lib/link'
import { useSmartFollowingListNavigation } from '@/PageManager'
-import { useFollowList } from '@/providers/FollowListProvider'
+import { useFollowListOptional } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
@@ -9,7 +9,8 @@ import { useTranslation } from 'react-i18next'
export default function SmartFollowings({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { pubkey: accountPubkey } = useNostr()
- const { followings: selfFollowings } = useFollowList()
+ const followList = useFollowListOptional()
+ const selfFollowings = followList?.followings ?? []
const { followings, isFetching } = useFetchFollowings(pubkey)
const { navigateToFollowingList } = useSmartFollowingListNavigation()
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 4932758e..7e68a454 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -13,7 +13,9 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks'
import { kinds, type NostrEvent } from 'nostr-tools'
+import { createReactionDraftEvent } from '@/lib/draft-event'
import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
+import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { toProfileEditor } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey'
import { usePrimaryPage } from '@/contexts/primary-page-context'
@@ -28,7 +30,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
-import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link } from 'lucide-react'
+import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link, MessageCircle, ThumbsUp } from 'lucide-react'
import {
useEffect,
useLayoutEffect,
@@ -47,6 +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 SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays'
@@ -59,6 +62,8 @@ 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'
@@ -182,7 +187,7 @@ export default function Profile({
const mediaFeedRef = useRef(null)
const { profile, isFetching } = useFetchProfile(id)
- const { pubkey: accountPubkey } = useNostr()
+ const { pubkey: accountPubkey, publish, checkLogin } = useNostr()
const [paymentInfo, setPaymentInfo] = useState | null>(null)
const [profileEvent, setProfileEvent] = useState(undefined)
const [openZapDialog, setOpenZapDialog] = useState(false)
@@ -191,6 +196,8 @@ export default function Profile({
const [openScheduleOwnCall, setOpenScheduleOwnCall] = useState(false)
const [openScheduleInPersonMeeting, setOpenScheduleInPersonMeeting] = useState(false)
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
+ const [openSelfReply, setOpenSelfReply] = useState(false)
+ const [selfReacting, setSelfReacting] = useState(false)
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
@@ -280,6 +287,10 @@ 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)
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => {
@@ -343,6 +354,8 @@ export default function Profile({
const m = r as MutableRefObject<{ refresh: () => void } | null>
m.current = {
refresh: () => {
+ refreshProfileInteractions()
+ refreshProfileBadges()
postsFeedRef.current?.refresh()
mediaFeedRef.current?.refresh()
}
@@ -350,7 +363,7 @@ export default function Profile({
return () => {
m.current = null
}
- }, [])
+ }, [refreshProfileInteractions, refreshProfileBadges])
useEffect(() => {
if (!profile?.pubkey) return
@@ -414,6 +427,7 @@ export default function Profile({
? (url) => setOpenCallInviteTo({ pubkey, url })
: undefined
}
+ onProfileInteractionsRefresh={refreshProfileInteractions}
/>
{isSelf ? (
@@ -423,6 +437,38 @@ export default function Profile({
+ {profileEvent && (
+ <>
+ setOpenSelfReply(true)}>
+
+ {t('Reply')}
+
+ {
+ if (!profileEvent) return
+ checkLogin(async () => {
+ if (selfReacting) return
+ setSelfReacting(true)
+ try {
+ const reaction = createReactionDraftEvent(profileEvent, '+')
+ const evt = await publish(reaction)
+ if (evt) {
+ showSimplePublishSuccess(t('Reaction published'))
+ refreshProfileInteractions()
+ }
+ } finally {
+ setSelfReacting(false)
+ }
+ })
+ }}
+ disabled={selfReacting}
+ >
+
+ {selfReacting ? t('Publishing...') : t('Like')}
+
+
+ >
+ )}
setOpenScheduleOwnCall(true)}>
{t('Schedule a video call')}
@@ -458,14 +504,23 @@ export default function Profile({
)}
- ) : (
+ ) : null}
+ {profileEvent && isSelf && (
+
+ )}
+ {!isSelf ? (
<>
{mergedPaymentMethods.some((m) => m.type === 'lightning') && (
)}
>
- )}
+ ) : null}
@@ -485,6 +540,14 @@ export default function Profile({
+
void
/** Opens the post editor to send the call invite URL as a public message to this profile. */
onSendCallInvite?: (url: string) => void
+ /** Called after Like or Reply to refresh profile header interactions. */
+ onProfileInteractionsRefresh?: () => void
}) {
const { t } = useTranslation()
- const { pubkey: accountPubkey, profile } = useNostr()
+ const { pubkey: accountPubkey, profile, publish, checkLogin } = useNostr()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
+ const [openReply, setOpenReply] = useState(false)
+ const [reacting, setReacting] = useState(false)
const [monitoringListRelayCount, setMonitoringListRelayCount] = useState(null)
const [localProfileEvent, setLocalProfileEvent] = useState(profileEvent)
@@ -143,6 +151,26 @@ export default function ProfileOptions({
})
}
+ const eventToUse = localProfileEvent || profileEvent
+
+ const handleLike = () => {
+ if (!eventToUse) return
+ checkLogin(async () => {
+ if (reacting) return
+ setReacting(true)
+ try {
+ const reaction = createReactionDraftEvent(eventToUse, '+')
+ const evt = await publish(reaction)
+ if (evt) {
+ showSimplePublishSuccess(t('Reaction published'))
+ onProfileInteractionsRefresh?.()
+ }
+ } finally {
+ setReacting(false)
+ }
+ })
+ }
+
if (pubkey === accountPubkey) return null
const callInviteUrl =
@@ -160,6 +188,19 @@ export default function ProfileOptions({
+ {eventToUse && (
+ <>
+ setOpenReply(true)}>
+
+ {t('Reply')}
+
+
+
+ {reacting ? t('Publishing...') : t('Like')}
+
+
+ >
+ )}
{onSendPublicMessage && (
@@ -244,8 +285,16 @@ export default function ProfileOptions({
>
)}
- {(localProfileEvent || profileEvent) && (
-
+ )}
+ {(localProfileEvent || profileEvent) && (
+ setIsRawEventDialogOpen(false)}
diff --git a/src/constants.ts b/src/constants.ts
index 350153be..3655d7fe 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -296,7 +296,11 @@ export const ExtendedKind = {
/** NIP-52 Calendar event RSVP */
CALENDAR_EVENT_RSVP: 31925,
/** NIP-A7 Spells: portable relay query filters (kind 777) */
- SPELL: 777
+ SPELL: 777,
+ /** NIP-58 Badges: profile badges list (addressable, d=profile_badges) */
+ PROFILE_BADGES: 30008,
+ /** NIP-58 Badges: badge definition (addressable) */
+ BADGE_DEFINITION: 30009
}
/** NIP-52 calendar event kinds (addressable by d-tag); use in isReplaceableEvent. */
diff --git a/src/hooks/useProfileBadges.tsx b/src/hooks/useProfileBadges.tsx
new file mode 100644
index 00000000..2a859375
--- /dev/null
+++ b/src/hooks/useProfileBadges.tsx
@@ -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([])
+ 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 }
+}
diff --git a/src/hooks/useProfileInteractions.tsx b/src/hooks/useProfileInteractions.tsx
new file mode 100644
index 00000000..8b098a48
--- /dev/null
+++ b/src/hooks/useProfileInteractions.tsx
@@ -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([])
+ const [reactions, setReactions] = useState([])
+ const [comments, setComments] = useState([])
+ 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()
+ const seenReactions = new Set()
+
+ 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 }
+}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 33e83a49..328102ef 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -422,6 +422,7 @@ export default {
All: 'Alle',
Reactions: 'Reaktionen',
Zaps: 'Zaps',
+ Badges: 'Abzeichen',
'Enjoying Jumble?': 'Gefรคllt dir Jumble?',
'Your donation helps me maintain Jumble and make it better! ๐':
'Deine Spende hilft mir, Jumble zu pflegen und zu verbessern! ๐',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index b1e93422..052d0b72 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -415,6 +415,7 @@ export default {
All: 'All',
Reactions: 'Reactions',
Zaps: 'Zaps',
+ Badges: 'Badges',
'Enjoying Jumble?': 'Enjoying Jumble?',
'Your donation helps me maintain Jumble and make it better! ๐':
'Your donation helps me maintain Jumble and make it better! ๐',