@@ -35,8 +38,9 @@ function CompactProfileCard({ event }: { event: Event }) {
{profile.username}
@@ -46,10 +50,16 @@ function CompactProfileCard({ event }: { event: Event }) {
) : null}
{nip05s.length > 0 ? (
-
+
{nip05s.map((id) => (
-
- {id}
+
+ {id}
+
))}
diff --git a/src/components/FollowButton/index.tsx b/src/components/FollowButton/index.tsx
index a486e287..be1b228b 100644
--- a/src/components/FollowButton/index.tsx
+++ b/src/components/FollowButton/index.tsx
@@ -11,7 +11,7 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
-import { useFollowList } from '@/providers/FollowListProvider'
+import { useFollowListOptional } from '@/providers/FollowListProvider'
import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider'
import { useMemo, useState } from 'react'
@@ -21,14 +21,18 @@ import { toast } from 'sonner'
export default function FollowButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { pubkey: accountPubkey, checkLogin } = useNostr()
- const { followings, follow, unfollow } = useFollowList()
+ const followList = useFollowListOptional()
const { mutePubkeySet, unmutePubkey } = useMuteList()
const [updating, setUpdating] = useState(false)
const [hover, setHover] = useState(false)
+
+ const followings = followList?.followings ?? []
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey])
- if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null
+ if (!followList || !accountPubkey || (pubkey && pubkey === accountPubkey)) return null
+
+ const { follow, unfollow } = followList
const handleFollow = async (e: React.MouseEvent) => {
e.stopPropagation()
diff --git a/src/components/Profile/ProfileBadgeDetailDialog.tsx b/src/components/Profile/ProfileBadgeDetailDialog.tsx
new file mode 100644
index 00000000..7bcf8db2
--- /dev/null
+++ b/src/components/Profile/ProfileBadgeDetailDialog.tsx
@@ -0,0 +1,176 @@
+import UserAvatar from '@/components/UserAvatar'
+import Username from '@/components/Username'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import type { TProfileBadge } from '@/hooks/useProfileBadges'
+import { fetchBadgeRecipientPubkeys } from '@/lib/fetch-badge-recipient-pubkeys'
+import { toNote, toProfile } from '@/lib/link'
+import { hexPubkeysEqual } from '@/lib/pubkey'
+import { useSecondaryPage } from '@/PageManager'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+function parseIssuerPubkeyFromATag(aTag: string): string | undefined {
+ const parts = aTag.split(':')
+ if (parts.length < 2) return undefined
+ const pk = parts[1]
+ return /^[0-9a-f]{64}$/i.test(pk) ? pk.toLowerCase() : undefined
+}
+
+export default function ProfileBadgeDetailDialog({
+ open,
+ onOpenChange,
+ badge,
+ profilePubkey,
+ relayUrls
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ badge: TProfileBadge | null
+ profilePubkey: string
+ relayUrls: string[]
+}) {
+ const { t } = useTranslation()
+ const { push } = useSecondaryPage()
+ const [recipientPubkeys, setRecipientPubkeys] = useState([])
+ const [recipientsLoading, setRecipientsLoading] = useState(false)
+ const [recipientsError, setRecipientsError] = useState(false)
+
+ const issuerPubkey = useMemo(() => (badge ? parseIssuerPubkeyFromATag(badge.a) : undefined), [badge])
+
+ const displayImage = badge?.image ?? badge?.thumb
+ const displayThumb = badge?.thumb ?? badge?.image
+ const label = badge?.name ?? badge?.a.split(':').pop() ?? ''
+
+ useEffect(() => {
+ if (!open || !badge) {
+ setRecipientPubkeys([])
+ setRecipientsError(false)
+ setRecipientsLoading(false)
+ return
+ }
+
+ if (relayUrls.length === 0) {
+ setRecipientPubkeys([])
+ setRecipientsError(true)
+ return
+ }
+
+ let cancelled = false
+ setRecipientsLoading(true)
+ setRecipientsError(false)
+ fetchBadgeRecipientPubkeys(relayUrls, badge.a)
+ .then((pubkeys) => {
+ if (cancelled) return
+ pubkeys.sort((a, b) => a.localeCompare(b))
+ setRecipientPubkeys(pubkeys)
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setRecipientsError(true)
+ setRecipientPubkeys([])
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setRecipientsLoading(false)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [open, badge, relayUrls])
+
+ const otherRecipients = useMemo(
+ () => recipientPubkeys.filter((pk) => !hexPubkeysEqual(pk, profilePubkey)),
+ [recipientPubkeys, profilePubkey]
+ )
+
+ if (!badge) return null
+
+ return (
+
+ )
+}
diff --git a/src/components/Profile/ProfileHeaderInteractions.tsx b/src/components/Profile/ProfileHeaderInteractions.tsx
index c2a7a012..bb70be14 100644
--- a/src/components/Profile/ProfileHeaderInteractions.tsx
+++ b/src/components/Profile/ProfileHeaderInteractions.tsx
@@ -1,7 +1,10 @@
import Content from '@/components/Content'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
+import ProfileBadgeDetailDialog from './ProfileBadgeDetailDialog'
+import { Button } from '@/components/ui/button'
import { formatAmount } from '@/lib/lightning'
+import { cn } from '@/lib/utils'
import { toNote, toProfile } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import Emoji from '@/components/Emoji'
@@ -9,29 +12,50 @@ import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import type { TProfileZap } from '@/hooks/useProfileInteractions'
import type { TProfileBadge } from '@/hooks/useProfileBadges'
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks'
-import { Zap, MessageCircle, ThumbsDown, ThumbsUp, Users } from 'lucide-react'
+import { Flag, MoreHorizontal, Zap, MessageCircle, ThumbsDown, ThumbsUp, Users } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
import { Event } from 'nostr-tools'
type Props = {
+ profilePubkey: string
+ badgeRelayUrls: string[]
zaps: TProfileZap[]
reactions: Event[]
comments: Event[]
badges: TProfileBadge[]
followPacks: TProfileFollowPack[]
+ reports: Event[]
loading: boolean
badgesLoading: boolean
followPacksLoading: boolean
+ reportsLoading: boolean
+ /** When false (logged out), the Reports section is omitted — reports use the viewer’s relays only. */
+ reportsEnabled: boolean
}
const ZAPS_PER_ROW = 4
const ZAP_ROWS = 3
const MAX_ZAPS = ZAPS_PER_ROW * ZAP_ROWS
-const BADGES_PER_ROW = 4
+const LIKES_GRID_COLS = 4
+const LIKES_GRID_ROWS = 3
+const MAX_LIKES = LIKES_GRID_COLS * LIKES_GRID_ROWS
+const BADGES_PER_ROW = 6
const BADGE_ROWS = 2
const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS
+const BADGE_TILE_PX = 96
const MAX_FOLLOW_PACKS = 8
+const MAX_REPORTS = 12
+
+function reportSummaryFromEvent(event: Event): string {
+ const reportTag = event.tags.find((t) => t[0] === 'report')
+ const reason = reportTag?.[1]?.trim()
+ if (reason) return reason
+ const text = event.content.trim().replace(/\s+/g, ' ')
+ if (text) return text.length > 48 ? `${text.slice(0, 45)}…` : text
+ return '—'
+}
function ZapBadge({ zap }: { zap: TProfileZap }) {
const { push } = useSecondaryPage()
@@ -92,80 +116,161 @@ function CommentBadge({ event }: { event: Event }) {
)
}
+function ReportBadge({ event }: { event: Event }) {
+ const { push } = useSecondaryPage()
+ const summary = reportSummaryFromEvent(event)
+ return (
+
+ )
+}
+
function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) {
+ const { t } = useTranslation()
const { push } = useSecondaryPage()
+ const authorPk = pack.event.pubkey
return (
)
}
-function BadgeItem({ badge }: { badge: TProfileBadge }) {
+function BadgeItem({
+ badge,
+ onOpenDetail
+}: {
+ badge: TProfileBadge
+ onOpenDetail: (b: TProfileBadge) => void
+}) {
+ const { t } = useTranslation()
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')
+
+ {imageUrl ? (
+ <>
+

{
+ e.currentTarget.style.visibility = 'hidden'
+ const fallback = e.currentTarget.nextElementSibling as HTMLElement | null
+ fallback?.classList.remove('hidden')
+ }}
+ />
+
+ {label.slice(0, 3)}
+
+ >
+ ) : (
+
+ {label.slice(0, 3)}
+
+ )}
+
)
}
export default function ProfileHeaderInteractions({
+ profilePubkey,
+ badgeRelayUrls,
zaps,
reactions,
comments,
badges,
followPacks,
+ reports,
loading,
badgesLoading,
- followPacksLoading
+ followPacksLoading,
+ reportsLoading,
+ reportsEnabled
}: Props) {
const { t } = useTranslation()
+ const [badgeDialogOpen, setBadgeDialogOpen] = useState(false)
+ const [selectedBadge, setSelectedBadge] = useState
(null)
+
const displayZaps = zaps.slice(0, MAX_ZAPS)
+ const displayReactions = reactions.slice(0, MAX_LIKES)
const displayBadges = badges.slice(0, MAX_BADGES)
const displayFollowPacks = followPacks.slice(0, MAX_FOLLOW_PACKS)
+ const displayReports = reports.slice(0, MAX_REPORTS)
- const Section = ({ title, isEmpty, isLoading, children, skeletonCount = 6 }: {
+ const Section = ({
+ title,
+ isEmpty,
+ isLoading,
+ children,
+ skeletonCount = 6,
+ skeletonItemClassName,
+ skeletonGridClassName
+ }: {
title: string
isEmpty: boolean
isLoading: boolean
children: React.ReactNode
skeletonCount?: number
+ skeletonItemClassName?: string
+ skeletonGridClassName?: string
}) => (
{title}
{isLoading && isEmpty ? (
-
+
{Array.from({ length: skeletonCount }).map((_, i) => (
-
+
))}
) : isEmpty ? (
@@ -186,8 +291,8 @@ export default function ProfileHeaderInteractions({
-
- {reactions.map((item) => (
+
+ {displayReactions.map((item) => (
))}
@@ -199,20 +304,59 @@ export default function ProfileHeaderInteractions({
))}
-
-
- {displayBadges.map((badge) => (
-
+
+
+ {displayBadges.map((badge, index) => (
+ {
+ setSelectedBadge(b)
+ setBadgeDialogOpen(true)
+ }}
+ />
))}
-
+ {
+ setBadgeDialogOpen(o)
+ if (!o) setSelectedBadge(null)
+ }}
+ badge={selectedBadge}
+ profilePubkey={profilePubkey}
+ relayUrls={badgeRelayUrls}
+ />
+
{displayFollowPacks.map((pack) => (
))}
+ {reportsEnabled ? (
+
+
+ {displayReports.map((item) => (
+
+ ))}
+
+
+ ) : null}
)
}
diff --git a/src/components/Profile/ProfileInteractionsAccordion.tsx b/src/components/Profile/ProfileInteractionsAccordion.tsx
index 1b5c742d..9d361e55 100644
--- a/src/components/Profile/ProfileInteractionsAccordion.tsx
+++ b/src/components/Profile/ProfileInteractionsAccordion.tsx
@@ -8,6 +8,8 @@ import { useProfileRelayUrls } from '@/hooks/useProfileRelayUrls'
import { useProfileInteractions } from '@/hooks/useProfileInteractions'
import { useProfileBadges } from '@/hooks/useProfileBadges'
import { useProfileFollowPacks } from '@/hooks/useProfileFollowPacks'
+import { useProfileReports } from '@/hooks/useProfileReports'
+import { useNostr } from '@/providers/NostrProvider'
import ProfileHeaderInteractions from './ProfileHeaderInteractions'
type Props = {
@@ -22,30 +24,38 @@ function ProfileInteractionsContent({ pubkey, relayUrls, onRefreshReady }: {
relayUrls: string[] | undefined
onRefreshReady?: (refresh: (() => void) | null) => void
}) {
+ const { pubkey: viewerPubkey } = useNostr()
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)
+ const { reports, loading: reportsLoading, refresh: refreshReports } = useProfileReports(pubkey, viewerPubkey)
useEffect(() => {
const doRefresh = () => {
refresh()
refreshBadges()
refreshFollowPacks()
+ refreshReports()
}
onRefreshReady?.(doRefresh)
return () => { onRefreshReady?.(null) }
- }, [refresh, refreshBadges, refreshFollowPacks, onRefreshReady])
+ }, [refresh, refreshBadges, refreshFollowPacks, refreshReports, onRefreshReady])
return (
)
}
@@ -53,12 +63,21 @@ function ProfileInteractionsContent({ pubkey, relayUrls, onRefreshReady }: {
function ProfileInteractionsSkeleton() {
return (
- {[6, 4, 4, 8, 6].map((count, i) => (
+ {[6, 4, 4, 8, 6, 6].map((count, i) => (
-
+
{Array.from({ length: count }).map((_, j) => (
-
+
))}
@@ -82,7 +101,7 @@ export default function ProfileInteractionsAccordion({
- {t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}
+ {t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}, {t('Reports')}
-
-
{ profileInteractionsRefreshRef.current = refresh ?? null }}
- />
-
{!isSelf && }
+
+
{ profileInteractionsRefreshRef.current = refresh ?? null }}
+ />
+
diff --git a/src/constants.ts b/src/constants.ts
index 4e0704da..d94b90d8 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -320,6 +320,8 @@ export const ExtendedKind = {
APPLICATION_HANDLER_INFO: 31990,
PAYMENT_INFO: 10133,
FOLLOW_PACK: 39089,
+ /** NIP-56: reporting / flagging (tagged `p` for reported pubkey, optional `e` for reported note) */
+ REPORT: 1984,
/** NIP-94 File Metadata (e.g. GIFs) */
FILE_METADATA: 1063,
/** NIP-66 Relay discovery (relay characteristics from NIP-11 or probing) */
diff --git a/src/hooks/useProfileBadges.tsx b/src/hooks/useProfileBadges.tsx
index 2f4a4d71..66b37eb8 100644
--- a/src/hooks/useProfileBadges.tsx
+++ b/src/hooks/useProfileBadges.tsx
@@ -1,4 +1,5 @@
import { ExtendedKind } from '@/constants'
+import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media'
import { queryService, replaceableEventService } from '@/services/client.service'
import { useCallback, useEffect, useRef, useState } from 'react'
import { tagNameEquals } from '@/lib/tag'
@@ -16,6 +17,8 @@ export type TProfileBadge = {
image?: string
/** Thumbnail URL (prefer thumb over image for grid display) */
thumb?: string
+ /** From badge definition (NIP-58) */
+ description?: string
}
/** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */
@@ -88,16 +91,22 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
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]
+ if (!defEvent) {
+ result.push({ a, awardId: e })
+ continue
+ }
+
+ const name = defEvent.tags.find(tagNameEquals('name'))?.[1]
+ const description = defEvent.tags.find(tagNameEquals('description'))?.[1]
+ const media = extractBadgeDefinitionMedia(defEvent)
result.push({
a,
awardId: e,
name: name ?? parsed.d,
- image,
- thumb: thumb ?? image
+ image: media.image,
+ thumb: media.thumb ?? media.image,
+ description
})
}
diff --git a/src/hooks/useProfileInteractions.tsx b/src/hooks/useProfileInteractions.tsx
index e454bee8..01d80c88 100644
--- a/src/hooks/useProfileInteractions.tsx
+++ b/src/hooks/useProfileInteractions.tsx
@@ -1,6 +1,6 @@
import { ExtendedKind } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
-import { queryService } from '@/services/client.service'
+import { queryService, replaceableEventService } from '@/services/client.service'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { Event, Filter, kinds } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -15,9 +15,9 @@ export type TProfileZap = {
comment?: string
}
-const NOTE_IDS_FOR_REACTIONS = 50
+const NOTE_IDS_FOR_COMMENTS = 50
-/** Fetches zaps, reactions (likes on profile's notes), and comments (on profile's notes). */
+/** Fetches zaps, reactions (likes on the kind-0 profile metadata event only), and comments (on the user's notes + profile). */
/** 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()
@@ -41,17 +41,24 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s
try {
const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays))
+ const profileMetaPromise = replaceableEventService.fetchReplaceableEvent(
+ pubkey,
+ kinds.Metadata,
+ undefined,
+ urls
+ )
+
const collectedZaps: TProfileZap[] = []
- const reactionsByPubkey = new Map
() // one reaction per npub, newest kept
+ const reactionsByPubkey = new Map() // one reaction per npub, newest kept (profile event only)
const collectedComments: Event[] = []
const seenZaps = new Set()
const seenReactions = new Set()
let noteIds: string[] = []
- // Phase 1: zaps + profile's recent notes (to find reactions/comments on their content)
+ // Phase 1: zaps + profile's recent notes (for comments on those notes)
const phase1Filters: Filter[] = [
{ '#p': [pubkey], kinds: [kinds.Zap], limit: 100 },
- { authors: [pubkey], kinds: [kinds.ShortTextNote], limit: NOTE_IDS_FOR_REACTIONS }
+ { authors: [pubkey], kinds: [kinds.ShortTextNote], limit: NOTE_IDS_FOR_COMMENTS }
]
const flushZaps = () => {
@@ -85,9 +92,24 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s
}
})
- noteIds = [...new Set(noteIds)].slice(0, NOTE_IDS_FOR_REACTIONS)
+ noteIds = [...new Set(noteIds)].slice(0, NOTE_IDS_FOR_COMMENTS)
+ if (myFetchId !== fetchIdRef.current) return
+
+ const profileMetaEvent = await profileMetaPromise
if (myFetchId !== fetchIdRef.current) return
+ const profileReactionATags = new Set([`0:${pubkey}:`, `0:${pubkey}:profile`])
+ const reactionTargetsKind0Profile = (evt: Event): boolean => {
+ if (evt.kind !== kinds.Reaction) return false
+ const aHit = evt.tags.some((t) => t[0] === 'a' && t[1] && profileReactionATags.has(t[1]))
+ if (aHit) return true
+ const pid = profileMetaEvent?.id
+ if (!pid) return false
+ return evt.tags.some(
+ (t) => t[0] === 'e' && t[1] && hexPubkeysEqual(t[1], pid)
+ )
+ }
+
const flushReactions = () => {
if (myFetchId !== fetchIdRef.current) return
setReactions(Array.from(reactionsByPubkey.values()).sort((a, b) => b.created_at - a.created_at))
@@ -96,40 +118,43 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s
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)
+ const ingestProfileReaction = (evt: Event) => {
+ if (!reactionTargetsKind0Profile(evt)) return
+ if (hexPubkeysEqual(evt.pubkey, pubkey)) return
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 existing = reactionsByPubkey.get(evt.pubkey)
+ if (!existing || evt.created_at > existing.created_at) {
+ reactionsByPubkey.set(evt.pubkey, evt)
}
+ flushReactions()
+ }
+ const ingestComment = (evt: Event) => {
+ if (hexPubkeysEqual(evt.pubkey, pubkey)) return
+ if (seenReactions.has(evt.id)) return
+ seenReactions.add(evt.id)
+ collectedComments.push(evt)
+ flushComments()
}
- const phase2Opts = {
+ const phase2CommentOpts = {
eoseTimeout: 2000,
globalTimeout: 15000,
firstRelayResultGraceMs: false as const,
onevent: (evt: Event) => {
- if (evt.kind === kinds.Reaction || evt.kind === ExtendedKind.COMMENT) {
- handleReactionOrComment(evt)
+ if (evt.kind === ExtendedKind.COMMENT) {
+ ingestComment(evt)
}
}
}
- // Phase 2a: reactions and comments on profile's notes (#e)
+ // Phase 2a: comments on profile's notes (#e) only
if (noteIds.length > 0) {
await queryService.fetchEvents(urls, [{
'#e': noteIds,
- kinds: [kinds.Reaction, ExtendedKind.COMMENT],
+ kinds: [ExtendedKind.COMMENT],
limit: 50
- }], phase2Opts)
+ }], phase2CommentOpts)
}
// Phase 2b: comments ON the profile itself (kind 0) - use #a (required), p is optional
@@ -138,7 +163,24 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s
'#a': profileAddrs,
kinds: [ExtendedKind.COMMENT],
limit: 50
- }], phase2Opts)
+ }], phase2CommentOpts)
+
+ // Phase 2c: reactions (likes) on the kind-0 profile metadata event only (#e + event id, and/or #a coordinates)
+ const profileReactionFilters: Filter[] = []
+ if (profileMetaEvent?.id) {
+ profileReactionFilters.push({ '#e': [profileMetaEvent.id], kinds: [kinds.Reaction], limit: 80 })
+ }
+ profileReactionFilters.push({ '#a': [...profileReactionATags], kinds: [kinds.Reaction], limit: 80 })
+ await queryService.fetchEvents(urls, profileReactionFilters, {
+ eoseTimeout: 2000,
+ globalTimeout: 15000,
+ firstRelayResultGraceMs: false,
+ onevent: (evt: Event) => {
+ if (evt.kind === kinds.Reaction) {
+ ingestProfileReaction(evt)
+ }
+ }
+ })
if (myFetchId !== fetchIdRef.current) return
collectedZaps.sort((a, b) => b.amount - a.amount)
diff --git a/src/hooks/useProfileReports.tsx b/src/hooks/useProfileReports.tsx
new file mode 100644
index 00000000..c6eb220a
--- /dev/null
+++ b/src/hooks/useProfileReports.tsx
@@ -0,0 +1,72 @@
+import { ExtendedKind } from '@/constants'
+import { buildProfileReportRelayUrls } from '@/lib/profile-report-relay-urls'
+import { queryService } from '@/services/client.service'
+import { Event } from 'nostr-tools'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+
+const REPORT_LIMIT = 50
+
+/** NIP-56 reports (kind 1984) about `profilePubkey`, from viewer favorites + inboxes only. */
+export function useProfileReports(
+ profilePubkey: string | undefined,
+ viewerPubkey: string | null | undefined
+) {
+ const { favoriteRelays, blockedRelays } = useFavoriteRelays()
+ const [reports, setReports] = useState([])
+ const [loading, setLoading] = useState(false)
+ const fetchIdRef = useRef(0)
+
+ const fetchReports = useCallback(async () => {
+ const viewer = viewerPubkey?.trim()
+ if (!profilePubkey || !viewer) {
+ setReports([])
+ setLoading(false)
+ return
+ }
+
+ const myFetchId = (fetchIdRef.current += 1)
+ setLoading(true)
+
+ try {
+ const urls = await buildProfileReportRelayUrls({
+ viewerPubkey: viewer,
+ favoriteRelays: favoriteRelays ?? [],
+ blockedRelays
+ })
+ if (urls.length === 0) {
+ if (myFetchId === fetchIdRef.current) setReports([])
+ return
+ }
+
+ const events = await queryService.fetchEvents(
+ urls,
+ [{ '#p': [profilePubkey], kinds: [ExtendedKind.REPORT], limit: REPORT_LIMIT }],
+ { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false }
+ )
+
+ if (myFetchId !== fetchIdRef.current) return
+
+ const seen = new Set()
+ const deduped: Event[] = []
+ for (const evt of events) {
+ if (seen.has(evt.id)) continue
+ seen.add(evt.id)
+ deduped.push(evt)
+ }
+ deduped.sort((a, b) => b.created_at - a.created_at)
+ setReports(deduped)
+ } catch {
+ if (myFetchId !== fetchIdRef.current) return
+ setReports([])
+ } finally {
+ if (myFetchId === fetchIdRef.current) setLoading(false)
+ }
+ }, [profilePubkey, viewerPubkey, favoriteRelays, blockedRelays])
+
+ useEffect(() => {
+ fetchReports()
+ }, [fetchReports])
+
+ return { reports, loading, refresh: fetchReports }
+}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index fcaad1bc..4a172632 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -431,6 +431,7 @@ export default {
Zaps: 'Zaps',
Boosts: 'Boosts',
Badges: 'Abzeichen',
+ Reports: 'Meldungen',
'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! 😊',
@@ -492,7 +493,14 @@ export default {
Bookmarks: 'Lesezeichen',
'Follow Packs': 'Follow-Packs',
'Follow Pack': 'Follow-Pack',
+ 'Follow pack by': 'von',
'In Follow Packs': 'In Follow-Packs',
+ 'Badge details': 'Abzeichen-Details',
+ 'Issued by': 'Ausgestellt von',
+ 'Other recipients': 'Weitere Empfänger:innen',
+ 'No other recipients found': 'Keine weiteren Empfänger:innen gefunden',
+ 'Recipients could not be loaded': 'Empfänger:innen konnten nicht geladen werden',
+ 'View award': 'Verleihung anzeigen',
'Please log in to follow': 'Zum Folgen bitte anmelden',
'Following All': 'Allen gefolgt',
'Followed {{count}} users': '{{count}} Nutzer:innen gefolgt',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index da199cef..37cf41f7 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -427,6 +427,7 @@ export default {
Zaps: 'Zaps',
Boosts: 'Boosts',
Badges: 'Badges',
+ Reports: 'Reports',
'Enjoying Jumble?': 'Enjoying Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
'Your donation helps me maintain Jumble and make it better! 😊',
@@ -487,7 +488,14 @@ export default {
Bookmarks: 'Bookmarks',
'Follow Packs': 'Follow Packs',
'Follow Pack': 'Follow Pack',
+ 'Follow pack by': 'by',
'In Follow Packs': 'In Follow Packs',
+ 'Badge details': 'Badge details',
+ 'Issued by': 'Issued by',
+ 'Other recipients': 'Other recipients',
+ 'No other recipients found': 'No other recipients found',
+ 'Recipients could not be loaded': 'Recipients could not be loaded',
+ 'View award': 'View award',
'Please log in to follow': 'Please log in to follow',
'Following All': 'Following All',
'Followed {{count}} users': 'Followed {{count}} users',
diff --git a/src/lib/badge-definition-media.ts b/src/lib/badge-definition-media.ts
new file mode 100644
index 00000000..003bc137
--- /dev/null
+++ b/src/lib/badge-definition-media.ts
@@ -0,0 +1,35 @@
+import { getImetaInfosFromEvent } from '@/lib/event'
+import { tagNameEquals } from '@/lib/tag'
+import { cleanUrl } from '@/lib/url'
+import { Event } from 'nostr-tools'
+
+export function resolveHttpMediaUrl(raw: string | undefined): string | undefined {
+ if (!raw?.trim()) return undefined
+ const s = raw.trim()
+ try {
+ const u = new URL(/^[a-z]+:\/\//i.test(s) ? s : `https://${s}`)
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return undefined
+ return cleanUrl(u.toString()) || u.toString()
+ } catch {
+ return undefined
+ }
+}
+
+/** Resolve `image` / `thumb` / `imeta` URLs from a NIP-58 badge definition (kind 30009). */
+export function extractBadgeDefinitionMedia(defEvent: Event | undefined): {
+ image?: string
+ thumb?: string
+} {
+ if (!defEvent) return {}
+ const tagImage = defEvent.tags.find(tagNameEquals('image'))?.[1]
+ const tagThumb = defEvent.tags.find(tagNameEquals('thumb'))?.[1]
+ const imetaUrls = getImetaInfosFromEvent(defEvent)
+ .map((i) => i.url)
+ .filter(Boolean) as string[]
+ const orderedThumb = [tagThumb, tagImage, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean)
+ const orderedImage = [tagImage, tagThumb, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean)
+ return {
+ thumb: orderedThumb ?? orderedImage,
+ image: orderedImage ?? orderedThumb
+ }
+}
diff --git a/src/lib/fetch-badge-recipient-pubkeys.ts b/src/lib/fetch-badge-recipient-pubkeys.ts
new file mode 100644
index 00000000..a1cd5fd0
--- /dev/null
+++ b/src/lib/fetch-badge-recipient-pubkeys.ts
@@ -0,0 +1,28 @@
+import { ExtendedKind } from '@/constants'
+import { queryService } from '@/services/client.service'
+import { Event } from 'nostr-tools'
+
+function profileBadgesEventReferencesA(ev: Event, badgeATag: string): boolean {
+ return ev.tags.some((t) => t[0] === 'a' && t[1] === badgeATag)
+}
+
+/**
+ * Pubkeys whose latest profile badge lists (kind 30008) include this badge definition `a` tag.
+ * Uses the same relay set as other profile fetches (typically outbox + profile mirrors).
+ */
+export async function fetchBadgeRecipientPubkeys(
+ relayUrls: string[],
+ badgeATag: string
+): Promise {
+ if (relayUrls.length === 0 || !badgeATag) return []
+ const events = await queryService.fetchEvents(
+ relayUrls,
+ [{ kinds: [ExtendedKind.PROFILE_BADGES], '#a': [badgeATag], limit: 200 }],
+ { eoseTimeout: 2500, globalTimeout: 18000, firstRelayResultGraceMs: false }
+ )
+ const authors = new Set()
+ for (const ev of events) {
+ if (profileBadgesEventReferencesA(ev, badgeATag)) authors.add(ev.pubkey)
+ }
+ return [...authors]
+}
diff --git a/src/lib/profile-report-relay-urls.ts b/src/lib/profile-report-relay-urls.ts
new file mode 100644
index 00000000..bed0bc07
--- /dev/null
+++ b/src/lib/profile-report-relay-urls.ts
@@ -0,0 +1,27 @@
+/**
+ * Relays for profile NIP-56 reports (kind 1984): only the viewer’s favorite tier and read (inbox)
+ * relays — no profile outboxes or global read mirrors, to limit abusive report spam.
+ */
+
+import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
+import { mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority'
+import { normalizeUrl } from '@/lib/url'
+import client from '@/services/client.service'
+
+const MAX_PROFILE_REPORT_RELAYS = 28
+
+export async function buildProfileReportRelayUrls(options: {
+ viewerPubkey: string
+ favoriteRelays: string[]
+ blockedRelays: string[]
+}): Promise {
+ const { viewerPubkey, favoriteRelays, blockedRelays } = options
+ const list = await client.fetchRelayList(viewerPubkey).catch(() => ({ read: [] as string[], write: [] as string[] }))
+ const inbox = relayUrlsLocalsFirst(list.read ?? [])
+ .map((u) => normalizeUrl(u) || u)
+ .filter(Boolean) as string[]
+ const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
+ return mergeRelayPriorityLayers([favorites, inbox], blockedRelays, MAX_PROFILE_REPORT_RELAYS, {
+ applySocialKindBlockedFilter: false
+ })
+}