Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
3b485409fb
  1. 29
      src/components/ContentPreview/FollowPackPreview.tsx
  2. 14
      src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx
  3. 10
      src/components/FollowButton/index.tsx
  4. 176
      src/components/Profile/ProfileBadgeDetailDialog.tsx
  5. 208
      src/components/Profile/ProfileHeaderInteractions.tsx
  6. 29
      src/components/Profile/ProfileInteractionsAccordion.tsx
  7. 16
      src/components/Profile/index.tsx
  8. 2
      src/constants.ts
  9. 19
      src/hooks/useProfileBadges.tsx
  10. 80
      src/hooks/useProfileInteractions.tsx
  11. 72
      src/hooks/useProfileReports.tsx
  12. 8
      src/i18n/locales/de.ts
  13. 8
      src/i18n/locales/en.ts
  14. 35
      src/lib/badge-definition-media.ts
  15. 28
      src/lib/fetch-badge-recipient-pubkeys.ts
  16. 27
      src/lib/profile-report-relay-urls.ts

29
src/components/ContentPreview/FollowPackPreview.tsx

@ -1,7 +1,7 @@
import { getPubkeysFromPTags } from '@/lib/tag' import { getPubkeysFromPTags } from '@/lib/tag'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useFollowList } from '@/providers/FollowListProvider' import { useFollowListOptional } from '@/providers/FollowListProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -9,8 +9,9 @@ import { Users } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { SimpleUserAvatar } from '../UserAvatar' import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar'
import { Button } from '../ui/button' import Username from '@/components/Username'
import { Button } from '@/components/ui/button'
export default function FollowPackPreview({ export default function FollowPackPreview({
event, event,
@ -21,7 +22,8 @@ export default function FollowPackPreview({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { followings, follow } = useFollowList() const followList = useFollowListOptional()
const followings = followList?.followings ?? []
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@ -56,6 +58,8 @@ export default function FollowPackPreview({
toast.error(t('Please log in to follow')) toast.error(t('Please log in to follow'))
return return
} }
if (!followList) return
const { follow } = followList
const toFollow = packPubkeys.filter((p) => !followingSet.has(p) && !mutePubkeySet.has(p)) const toFollow = packPubkeys.filter((p) => !followingSet.has(p) && !mutePubkeySet.has(p))
if (toFollow.length === 0) { if (toFollow.length === 0) {
const mutedCount = packPubkeys.filter((p) => mutePubkeySet.has(p) && !followingSet.has(p)).length const mutedCount = packPubkeys.filter((p) => mutePubkeySet.has(p) && !followingSet.has(p)).length
@ -79,15 +83,26 @@ export default function FollowPackPreview({
setBusy(false) setBusy(false)
} }
}, },
[pubkey, packPubkeys, followingSet, mutePubkeySet, follow, t] [pubkey, followList, packPubkeys, followingSet, mutePubkeySet, t]
) )
return ( return (
<div className={cn('rounded-lg border bg-muted/30 p-3', className)}> <div className={cn('rounded-lg border bg-muted/30 p-3', className)}>
<div className="mb-2 flex items-center gap-1"> <div className="mb-2 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm text-muted-foreground">[{t('Follow Pack')}]</span> <span className="text-sm text-muted-foreground">[{t('Follow Pack')}]</span>
<span className="text-sm font-semibold">{title}</span> <span className="text-sm font-semibold">{title}</span>
</div> </div>
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span className="shrink-0">{t('Follow pack by')}:</span>
<UserAvatar userId={event.pubkey} size="xSmall" className="shrink-0" />
<Username
userId={event.pubkey}
className="min-w-0 truncate font-medium text-foreground"
skeletonClassName="h-3"
/>
</div>
</div>
{description ? ( {description ? (
<div className="mb-3 line-clamp-2 text-sm text-muted-foreground">{description}</div> <div className="mb-3 line-clamp-2 text-sm text-muted-foreground">{description}</div>
@ -120,7 +135,7 @@ export default function FollowPackPreview({
{!pubkey ? ( {!pubkey ? (
<p className="text-sm text-muted-foreground">{t('Please log in to follow')}</p> <p className="text-sm text-muted-foreground">{t('Please log in to follow')}</p>
) : ( ) : !followList ? null : (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

14
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

@ -28,6 +28,9 @@ function CompactProfileCard({ event }: { event: Event }) {
const profile = getProfileFromEvent(event) const profile = getProfileFromEvent(event)
const nip05s = collectAggregatedNip05sFromKind0(event) const nip05s = collectAggregatedNip05sFromKind0(event)
const about = truncateAbout(profile.about, ABOUT_PREVIEW_LEN) const about = truncateAbout(profile.about, ABOUT_PREVIEW_LEN)
const { setActiveNpubsDrawerOpen } = useFavoriteRelaysActivity()
const profileUrl = toProfile(event.pubkey)
const closeDrawer = () => setActiveNpubsDrawerOpen(false)
return ( return (
<div className="rounded-lg border border-border/80 bg-muted/20 p-3"> <div className="rounded-lg border border-border/80 bg-muted/20 p-3">
@ -35,8 +38,9 @@ function CompactProfileCard({ event }: { event: Event }) {
<UserAvatar userId={event.pubkey} size="semiBig" /> <UserAvatar userId={event.pubkey} size="semiBig" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<SecondaryPageLink <SecondaryPageLink
to={toProfile(event.pubkey)} to={profileUrl}
className="font-semibold text-foreground hover:underline" className="font-semibold text-foreground hover:underline"
onClick={closeDrawer}
> >
{profile.username} {profile.username}
</SecondaryPageLink> </SecondaryPageLink>
@ -46,10 +50,16 @@ function CompactProfileCard({ event }: { event: Event }) {
</p> </p>
) : null} ) : null}
{nip05s.length > 0 ? ( {nip05s.length > 0 ? (
<ul className="mt-2 space-y-0.5 text-xs text-primary"> <ul className="mt-2 space-y-0.5 text-xs">
{nip05s.map((id) => ( {nip05s.map((id) => (
<li key={id} className="truncate font-mono"> <li key={id} className="truncate font-mono">
<SecondaryPageLink
to={profileUrl}
className="text-primary hover:underline"
onClick={closeDrawer}
>
{id} {id}
</SecondaryPageLink>
</li> </li>
))} ))}
</ul> </ul>

10
src/components/FollowButton/index.tsx

@ -11,7 +11,7 @@ import {
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFollowList } from '@/providers/FollowListProvider' import { useFollowListOptional } from '@/providers/FollowListProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@ -21,14 +21,18 @@ import { toast } from 'sonner'
export default function FollowButton({ pubkey }: { pubkey: string }) { export default function FollowButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: accountPubkey, checkLogin } = useNostr() const { pubkey: accountPubkey, checkLogin } = useNostr()
const { followings, follow, unfollow } = useFollowList() const followList = useFollowListOptional()
const { mutePubkeySet, unmutePubkey } = useMuteList() const { mutePubkeySet, unmutePubkey } = useMuteList()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [hover, setHover] = useState(false) const [hover, setHover] = useState(false)
const followings = followList?.followings ?? []
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey]) const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, 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) => { const handleFollow = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()

176
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<string[]>([])
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] flex flex-col gap-3">
<DialogHeader>
<DialogTitle>{t('Badge details')}</DialogTitle>
<DialogDescription className="sr-only">{label}</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-2">
{displayImage || displayThumb ? (
<img
src={displayImage ?? displayThumb}
alt={label}
className="max-h-48 w-auto max-w-full rounded-lg border object-contain bg-muted"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex size-32 items-center justify-center rounded-lg border bg-muted text-sm text-muted-foreground">
{label.slice(0, 3)}
</div>
)}
<div className="text-center text-base font-semibold">{label}</div>
</div>
{badge.description ? (
<p className="text-sm text-muted-foreground whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{badge.description}
</p>
) : null}
{issuerPubkey ? (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">{t('Issued by')}</div>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5 text-left hover:bg-muted/60"
onClick={() => push(toProfile(issuerPubkey))}
>
<UserAvatar userId={issuerPubkey} size="small" className="shrink-0" />
<Username userId={issuerPubkey} className="truncate text-sm font-medium" skeletonClassName="h-4" />
</button>
</div>
) : null}
<div className="space-y-1 min-h-0 flex-1 flex flex-col">
<div className="text-xs font-medium text-muted-foreground">{t('Other recipients')}</div>
{recipientsLoading ? (
<div className="text-sm text-muted-foreground py-2">{t('Loading...')}</div>
) : recipientsError ? (
<div className="text-sm text-muted-foreground py-2">{t('Recipients could not be loaded')}</div>
) : otherRecipients.length === 0 ? (
<div className="text-sm text-muted-foreground py-2">{t('No other recipients found')}</div>
) : (
<ScrollArea className="h-44 rounded-md border">
<ul className="p-1 space-y-0.5">
{otherRecipients.map((pk) => (
<li key={pk}>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-muted/80"
onClick={() => push(toProfile(pk))}
>
<UserAvatar userId={pk} size="small" className="shrink-0" />
<Username userId={pk} className="truncate text-sm" skeletonClassName="h-4" />
</button>
</li>
))}
</ul>
</ScrollArea>
)}
</div>
<Button type="button" variant="secondary" className="w-full" onClick={() => push(toNote(badge.awardId))}>
{t('View award')}
</Button>
</DialogContent>
</Dialog>
)
}

208
src/components/Profile/ProfileHeaderInteractions.tsx

@ -1,7 +1,10 @@
import Content from '@/components/Content' import Content from '@/components/Content'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import ProfileBadgeDetailDialog from './ProfileBadgeDetailDialog'
import { Button } from '@/components/ui/button'
import { formatAmount } from '@/lib/lightning' import { formatAmount } from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { toNote, toProfile } from '@/lib/link' import { toNote, toProfile } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import Emoji from '@/components/Emoji' import Emoji from '@/components/Emoji'
@ -9,29 +12,50 @@ 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 type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks' 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 { Skeleton } from '@/components/ui/skeleton'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
type Props = { type Props = {
profilePubkey: string
badgeRelayUrls: string[]
zaps: TProfileZap[] zaps: TProfileZap[]
reactions: Event[] reactions: Event[]
comments: Event[] comments: Event[]
badges: TProfileBadge[] badges: TProfileBadge[]
followPacks: TProfileFollowPack[] followPacks: TProfileFollowPack[]
reports: Event[]
loading: boolean loading: boolean
badgesLoading: boolean badgesLoading: boolean
followPacksLoading: 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 ZAPS_PER_ROW = 4
const ZAP_ROWS = 3 const ZAP_ROWS = 3
const MAX_ZAPS = ZAPS_PER_ROW * ZAP_ROWS 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 BADGE_ROWS = 2
const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS const MAX_BADGES = BADGES_PER_ROW * BADGE_ROWS
const BADGE_TILE_PX = 96
const MAX_FOLLOW_PACKS = 8 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 }) { function ZapBadge({ zap }: { zap: TProfileZap }) {
const { push } = useSecondaryPage() 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 (
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border border-destructive/25 hover:bg-muted cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(event.id))}
title={summary}
>
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
<Flag className="size-3 shrink-0 text-destructive" strokeWidth={2} aria-hidden />
<span className="truncate text-xs text-muted-foreground min-w-0">{summary}</span>
</button>
)
}
function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) { function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) {
const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const authorPk = pack.event.pubkey
return ( return (
<button <button
type="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" className="flex flex-col gap-1 px-2 py-1.5 rounded-md bg-muted/80 border hover:bg-muted cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(pack.event.id))} onClick={() => push(toNote(pack.event.id))}
title={pack.title} title={pack.title}
> >
<div className="flex min-w-0 items-center gap-1.5">
<Users className="size-3 shrink-0 text-primary" aria-hidden /> <Users className="size-3 shrink-0 text-primary" aria-hidden />
<span className="truncate text-xs text-foreground min-w-0">{pack.title}</span> <span className="truncate text-xs font-medium text-foreground min-w-0">{pack.title}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5 ps-4">
<span className="shrink-0 text-xs text-muted-foreground">{t('Follow pack by')}:</span>
<UserAvatar userId={authorPk} size="xSmall" className="shrink-0" />
<Username
userId={authorPk}
className="min-w-0 truncate text-xs font-medium text-foreground"
skeletonClassName="h-3.5"
/>
</div>
</button> </button>
) )
} }
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 imageUrl = badge.thumb ?? badge.image
const label = badge.name ?? badge.a.split(':').pop() ?? '' const label = badge.name ?? badge.a.split(':').pop() ?? ''
if (!imageUrl) {
return ( return (
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted text-xs text-muted-foreground" title={label}> <div
{label.slice(0, 2)} className="relative shrink-0 rounded-lg border bg-muted"
</div> style={{ width: BADGE_TILE_PX, height: BADGE_TILE_PX }}
) title={label}
} >
return ( {imageUrl ? (
<div className="relative size-12 shrink-0"> <>
<img <img
src={imageUrl} src={imageUrl}
alt={label} alt=""
title={label} className="size-full rounded-lg object-cover"
className="size-12 rounded-lg border object-cover bg-muted"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none' e.currentTarget.style.visibility = 'hidden'
const fallback = e.currentTarget.nextElementSibling as HTMLElement const fallback = e.currentTarget.nextElementSibling as HTMLElement | null
if (fallback) fallback.classList.remove('hidden') 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}> <div className="hidden absolute inset-0 flex items-center justify-center rounded-lg bg-muted p-1 text-center text-xs text-muted-foreground">
{label.slice(0, 2)} {label.slice(0, 3)}
</div>
</>
) : (
<div className="flex size-full items-center justify-center rounded-lg p-1 text-center text-xs text-muted-foreground">
{label.slice(0, 3)}
</div> </div>
)}
<Button
type="button"
variant="secondary"
size="icon"
className="absolute right-0.5 top-0.5 h-7 w-7 shrink-0 rounded-md border border-border/80 bg-background/90 shadow-sm backdrop-blur-sm hover:bg-background"
aria-label={t('Badge details')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onOpenDetail(badge)
}}
>
<MoreHorizontal className="size-4" aria-hidden />
</Button>
</div> </div>
) )
} }
export default function ProfileHeaderInteractions({ export default function ProfileHeaderInteractions({
profilePubkey,
badgeRelayUrls,
zaps, zaps,
reactions, reactions,
comments, comments,
badges, badges,
followPacks, followPacks,
reports,
loading, loading,
badgesLoading, badgesLoading,
followPacksLoading followPacksLoading,
reportsLoading,
reportsEnabled
}: Props) { }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const [badgeDialogOpen, setBadgeDialogOpen] = useState(false)
const [selectedBadge, setSelectedBadge] = useState<TProfileBadge | null>(null)
const displayZaps = zaps.slice(0, MAX_ZAPS) const displayZaps = zaps.slice(0, MAX_ZAPS)
const displayReactions = reactions.slice(0, MAX_LIKES)
const displayBadges = badges.slice(0, MAX_BADGES) const displayBadges = badges.slice(0, MAX_BADGES)
const displayFollowPacks = followPacks.slice(0, MAX_FOLLOW_PACKS) 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 title: string
isEmpty: boolean isEmpty: boolean
isLoading: boolean isLoading: boolean
children: React.ReactNode children: React.ReactNode
skeletonCount?: number skeletonCount?: number
skeletonItemClassName?: string
skeletonGridClassName?: string
}) => ( }) => (
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground mb-1.5">{title}</div> <div className="text-xs font-medium text-muted-foreground mb-1.5">{title}</div>
{isLoading && isEmpty ? ( {isLoading && isEmpty ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> <div
className={cn(
'grid gap-1.5',
skeletonGridClassName ?? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4'
)}
>
{Array.from({ length: skeletonCount }).map((_, i) => ( {Array.from({ length: skeletonCount }).map((_, i) => (
<Skeleton key={i} className="h-8 rounded-md min-w-0" /> <Skeleton key={i} className={cn('h-8 rounded-md min-w-0', skeletonItemClassName)} />
))} ))}
</div> </div>
) : isEmpty ? ( ) : isEmpty ? (
@ -186,8 +291,8 @@ export default function ProfileHeaderInteractions({
</div> </div>
</Section> </Section>
<Section title={t('Likes')} isEmpty={reactions.length === 0} isLoading={loading}> <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"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 grid-rows-3 gap-1.5">
{reactions.map((item) => ( {displayReactions.map((item) => (
<ReactionBadge key={`reaction-${item.id}`} event={item} /> <ReactionBadge key={`reaction-${item.id}`} event={item} />
))} ))}
</div> </div>
@ -199,20 +304,59 @@ export default function ProfileHeaderInteractions({
))} ))}
</div> </div>
</Section> </Section>
<Section title={t('Badges')} isEmpty={displayBadges.length === 0} isLoading={badgesLoading} skeletonCount={8}> <Section
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 grid-rows-2 gap-1.5"> title={t('Badges')}
{displayBadges.map((badge) => ( isEmpty={displayBadges.length === 0}
<BadgeItem key={`${badge.a}-${badge.awardId}`} badge={badge} /> isLoading={badgesLoading}
skeletonCount={12}
skeletonGridClassName="grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-1"
skeletonItemClassName="aspect-square h-24 w-full rounded-lg"
>
<div className="flex flex-wrap gap-1">
{displayBadges.map((badge, index) => (
<BadgeItem
key={`${badge.a}-${badge.awardId}-${index}`}
badge={badge}
onOpenDetail={(b) => {
setSelectedBadge(b)
setBadgeDialogOpen(true)
}}
/>
))} ))}
</div> </div>
</Section> </Section>
<Section title={t('In Follow Packs')} isEmpty={displayFollowPacks.length === 0} isLoading={followPacksLoading} skeletonCount={6}> <ProfileBadgeDetailDialog
open={badgeDialogOpen}
onOpenChange={(o) => {
setBadgeDialogOpen(o)
if (!o) setSelectedBadge(null)
}}
badge={selectedBadge}
profilePubkey={profilePubkey}
relayUrls={badgeRelayUrls}
/>
<Section
title={t('In Follow Packs')}
isEmpty={displayFollowPacks.length === 0}
isLoading={followPacksLoading}
skeletonCount={6}
skeletonItemClassName="h-14"
>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{displayFollowPacks.map((pack) => ( {displayFollowPacks.map((pack) => (
<FollowPackBadge key={pack.event.id} pack={pack} /> <FollowPackBadge key={pack.event.id} pack={pack} />
))} ))}
</div> </div>
</Section> </Section>
{reportsEnabled ? (
<Section title={t('Reports')} isEmpty={displayReports.length === 0} isLoading={reportsLoading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{displayReports.map((item) => (
<ReportBadge key={`report-${item.id}`} event={item} />
))}
</div>
</Section>
) : null}
</div> </div>
) )
} }

29
src/components/Profile/ProfileInteractionsAccordion.tsx

@ -8,6 +8,8 @@ import { useProfileRelayUrls } from '@/hooks/useProfileRelayUrls'
import { useProfileInteractions } from '@/hooks/useProfileInteractions' import { useProfileInteractions } from '@/hooks/useProfileInteractions'
import { useProfileBadges } from '@/hooks/useProfileBadges' import { useProfileBadges } from '@/hooks/useProfileBadges'
import { useProfileFollowPacks } from '@/hooks/useProfileFollowPacks' import { useProfileFollowPacks } from '@/hooks/useProfileFollowPacks'
import { useProfileReports } from '@/hooks/useProfileReports'
import { useNostr } from '@/providers/NostrProvider'
import ProfileHeaderInteractions from './ProfileHeaderInteractions' import ProfileHeaderInteractions from './ProfileHeaderInteractions'
type Props = { type Props = {
@ -22,30 +24,38 @@ function ProfileInteractionsContent({ pubkey, relayUrls, onRefreshReady }: {
relayUrls: string[] | undefined relayUrls: string[] | undefined
onRefreshReady?: (refresh: (() => void) | null) => void onRefreshReady?: (refresh: (() => void) | null) => void
}) { }) {
const { pubkey: viewerPubkey } = useNostr()
const { zaps, reactions, comments, loading, refresh } = useProfileInteractions(pubkey, relayUrls) const { zaps, reactions, comments, loading, refresh } = useProfileInteractions(pubkey, relayUrls)
const { badges, loading: badgesLoading, refresh: refreshBadges } = useProfileBadges(pubkey, relayUrls) const { badges, loading: badgesLoading, refresh: refreshBadges } = useProfileBadges(pubkey, relayUrls)
const { packs, loading: followPacksLoading, refresh: refreshFollowPacks } = useProfileFollowPacks(pubkey, relayUrls) const { packs, loading: followPacksLoading, refresh: refreshFollowPacks } = useProfileFollowPacks(pubkey, relayUrls)
const { reports, loading: reportsLoading, refresh: refreshReports } = useProfileReports(pubkey, viewerPubkey)
useEffect(() => { useEffect(() => {
const doRefresh = () => { const doRefresh = () => {
refresh() refresh()
refreshBadges() refreshBadges()
refreshFollowPacks() refreshFollowPacks()
refreshReports()
} }
onRefreshReady?.(doRefresh) onRefreshReady?.(doRefresh)
return () => { onRefreshReady?.(null) } return () => { onRefreshReady?.(null) }
}, [refresh, refreshBadges, refreshFollowPacks, onRefreshReady]) }, [refresh, refreshBadges, refreshFollowPacks, refreshReports, onRefreshReady])
return ( return (
<ProfileHeaderInteractions <ProfileHeaderInteractions
profilePubkey={pubkey}
badgeRelayUrls={relayUrls ?? []}
zaps={zaps} zaps={zaps}
reactions={reactions} reactions={reactions}
comments={comments} comments={comments}
badges={badges} badges={badges}
followPacks={packs} followPacks={packs}
reports={reports}
loading={loading} loading={loading}
badgesLoading={badgesLoading} badgesLoading={badgesLoading}
followPacksLoading={followPacksLoading} followPacksLoading={followPacksLoading}
reportsLoading={reportsLoading}
reportsEnabled={!!viewerPubkey}
/> />
) )
} }
@ -53,12 +63,21 @@ function ProfileInteractionsContent({ pubkey, relayUrls, onRefreshReady }: {
function ProfileInteractionsSkeleton() { function ProfileInteractionsSkeleton() {
return ( return (
<div className="py-2 space-y-3"> <div className="py-2 space-y-3">
{[6, 4, 4, 8, 6].map((count, i) => ( {[6, 4, 4, 8, 6, 6].map((count, i) => (
<div key={i} className="min-w-0"> <div key={i} className="min-w-0">
<Skeleton className="h-3 w-16 mb-1.5" /> <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"> <div
className={
i === 3
? 'grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-1'
: 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5'
}
>
{Array.from({ length: count }).map((_, j) => ( {Array.from({ length: count }).map((_, j) => (
<Skeleton key={j} className="h-8 rounded-md min-w-0" /> <Skeleton
key={j}
className={cn('rounded-lg min-w-0', i === 3 ? 'aspect-square h-24 w-full' : 'h-8')}
/>
))} ))}
</div> </div>
</div> </div>
@ -82,7 +101,7 @@ export default function ProfileInteractionsAccordion({
<Collapsible open={isExpanded} onOpenChange={onExpandedChange} className="min-w-0"> <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"> <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"> <span className="text-sm font-medium truncate">
{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')}
</span> </span>
<ChevronDown <ChevronDown
className={cn('size-4 shrink-0 text-muted-foreground transition-transform', isExpanded && 'rotate-180')} className={cn('size-4 shrink-0 text-muted-foreground transition-transform', isExpanded && 'rotate-180')}

16
src/components/Profile/index.tsx

@ -535,14 +535,6 @@ export default function Profile({
<PubkeyCopy pubkey={pubkey} showFull /> <PubkeyCopy pubkey={pubkey} showFull />
<NpubQrCode pubkey={pubkey} /> <NpubQrCode pubkey={pubkey} />
</div> </div>
<div className="mt-4 pt-2">
<ProfileInteractionsAccordion
pubkey={pubkey}
isExpanded={profileInteractionsExpanded}
onExpandedChange={setProfileInteractionsExpanded}
onRefreshReady={(refresh) => { profileInteractionsRefreshRef.current = refresh ?? null }}
/>
</div>
<Collapsible> <Collapsible>
<ProfileAbout <ProfileAbout
about={about} about={about}
@ -649,6 +641,14 @@ export default function Profile({
</div> </div>
{!isSelf && <FollowedBy pubkey={pubkey} />} {!isSelf && <FollowedBy pubkey={pubkey} />}
</div> </div>
<div className="mt-4 pt-2">
<ProfileInteractionsAccordion
pubkey={pubkey}
isExpanded={profileInteractionsExpanded}
onExpandedChange={setProfileInteractionsExpanded}
onRefreshReady={(refresh) => { profileInteractionsRefreshRef.current = refresh ?? null }}
/>
</div>
</div> </div>
</div> </div>
</div> </div>

2
src/constants.ts

@ -320,6 +320,8 @@ export const ExtendedKind = {
APPLICATION_HANDLER_INFO: 31990, APPLICATION_HANDLER_INFO: 31990,
PAYMENT_INFO: 10133, PAYMENT_INFO: 10133,
FOLLOW_PACK: 39089, 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) */ /** NIP-94 File Metadata (e.g. GIFs) */
FILE_METADATA: 1063, FILE_METADATA: 1063,
/** NIP-66 Relay discovery (relay characteristics from NIP-11 or probing) */ /** NIP-66 Relay discovery (relay characteristics from NIP-11 or probing) */

19
src/hooks/useProfileBadges.tsx

@ -1,4 +1,5 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media'
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'
@ -16,6 +17,8 @@ export type TProfileBadge = {
image?: string image?: string
/** Thumbnail URL (prefer thumb over image for grid display) */ /** Thumbnail URL (prefer thumb over image for grid display) */
thumb?: string thumb?: string
/** From badge definition (NIP-58) */
description?: string
} }
/** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */ /** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */
@ -88,16 +91,22 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
parsed.d parsed.d
) )
const name = defEvent?.tags.find(tagNameEquals('name'))?.[1] if (!defEvent) {
const image = defEvent?.tags.find(tagNameEquals('image'))?.[1] result.push({ a, awardId: e })
const thumb = defEvent?.tags.find(tagNameEquals('thumb'))?.[1] continue
}
const name = defEvent.tags.find(tagNameEquals('name'))?.[1]
const description = defEvent.tags.find(tagNameEquals('description'))?.[1]
const media = extractBadgeDefinitionMedia(defEvent)
result.push({ result.push({
a, a,
awardId: e, awardId: e,
name: name ?? parsed.d, name: name ?? parsed.d,
image, image: media.image,
thumb: thumb ?? image thumb: media.thumb ?? media.image,
description
}) })
} }

80
src/hooks/useProfileInteractions.tsx

@ -1,6 +1,6 @@
import { 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, replaceableEventService } from '@/services/client.service'
import { hexPubkeysEqual } from '@/lib/pubkey' 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'
@ -15,9 +15,9 @@ export type TProfileZap = {
comment?: string 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. */ /** 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[]) { export function useProfileInteractions(pubkey: string | undefined, relayUrls?: string[]) {
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
@ -41,17 +41,24 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s
try { try {
const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays)) const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays))
const profileMetaPromise = replaceableEventService.fetchReplaceableEvent(
pubkey,
kinds.Metadata,
undefined,
urls
)
const collectedZaps: TProfileZap[] = [] const collectedZaps: TProfileZap[] = []
const reactionsByPubkey = new Map<string, Event>() // one reaction per npub, newest kept const reactionsByPubkey = new Map<string, Event>() // one reaction per npub, newest kept (profile event only)
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[] = [] 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[] = [ const phase1Filters: Filter[] = [
{ '#p': [pubkey], kinds: [kinds.Zap], limit: 100 }, { '#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 = () => { 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 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 = () => { const flushReactions = () => {
if (myFetchId !== fetchIdRef.current) return if (myFetchId !== fetchIdRef.current) return
setReactions(Array.from(reactionsByPubkey.values()).sort((a, b) => b.created_at - a.created_at)) 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 if (myFetchId !== fetchIdRef.current) return
setComments([...collectedComments].sort((a, b) => b.created_at - a.created_at)) setComments([...collectedComments].sort((a, b) => b.created_at - a.created_at))
} }
const handleReactionOrComment = (evt: Event) => { const ingestProfileReaction = (evt: Event) => {
if (hexPubkeysEqual(evt.pubkey, pubkey)) return // skip self-reactions/self-comments (likely tests) if (!reactionTargetsKind0Profile(evt)) return
if (hexPubkeysEqual(evt.pubkey, pubkey)) return
if (seenReactions.has(evt.id)) return if (seenReactions.has(evt.id)) return
seenReactions.add(evt.id) seenReactions.add(evt.id)
if (evt.kind === kinds.Reaction) {
const existing = reactionsByPubkey.get(evt.pubkey) const existing = reactionsByPubkey.get(evt.pubkey)
if (!existing || evt.created_at > existing.created_at) { if (!existing || evt.created_at > existing.created_at) {
reactionsByPubkey.set(evt.pubkey, evt) reactionsByPubkey.set(evt.pubkey, evt)
} }
flushReactions() flushReactions()
} else { }
const ingestComment = (evt: Event) => {
if (hexPubkeysEqual(evt.pubkey, pubkey)) return
if (seenReactions.has(evt.id)) return
seenReactions.add(evt.id)
collectedComments.push(evt) collectedComments.push(evt)
flushComments() flushComments()
} }
}
const phase2Opts = { const phase2CommentOpts = {
eoseTimeout: 2000, eoseTimeout: 2000,
globalTimeout: 15000, globalTimeout: 15000,
firstRelayResultGraceMs: false as const, firstRelayResultGraceMs: false as const,
onevent: (evt: Event) => { onevent: (evt: Event) => {
if (evt.kind === kinds.Reaction || evt.kind === ExtendedKind.COMMENT) { if (evt.kind === ExtendedKind.COMMENT) {
handleReactionOrComment(evt) 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) { if (noteIds.length > 0) {
await queryService.fetchEvents(urls, [{ await queryService.fetchEvents(urls, [{
'#e': noteIds, '#e': noteIds,
kinds: [kinds.Reaction, ExtendedKind.COMMENT], kinds: [ExtendedKind.COMMENT],
limit: 50 limit: 50
}], phase2Opts) }], phase2CommentOpts)
} }
// Phase 2b: comments ON the profile itself (kind 0) - use #a (required), p is optional // 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, '#a': profileAddrs,
kinds: [ExtendedKind.COMMENT], kinds: [ExtendedKind.COMMENT],
limit: 50 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 if (myFetchId !== fetchIdRef.current) return
collectedZaps.sort((a, b) => b.amount - a.amount) collectedZaps.sort((a, b) => b.amount - a.amount)

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

8
src/i18n/locales/de.ts

@ -431,6 +431,7 @@ export default {
Zaps: 'Zaps', Zaps: 'Zaps',
Boosts: 'Boosts', Boosts: 'Boosts',
Badges: 'Abzeichen', Badges: 'Abzeichen',
Reports: 'Meldungen',
'Enjoying Jumble?': 'Gefällt dir Jumble?', 'Enjoying Jumble?': 'Gefällt dir Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊': 'Your donation helps me maintain Jumble and make it better! 😊':
'Deine Spende hilft mir, Jumble zu pflegen und zu verbessern! 😊', 'Deine Spende hilft mir, Jumble zu pflegen und zu verbessern! 😊',
@ -492,7 +493,14 @@ export default {
Bookmarks: 'Lesezeichen', Bookmarks: 'Lesezeichen',
'Follow Packs': 'Follow-Packs', 'Follow Packs': 'Follow-Packs',
'Follow Pack': 'Follow-Pack', 'Follow Pack': 'Follow-Pack',
'Follow pack by': 'von',
'In Follow Packs': 'In Follow-Packs', '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', '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',

8
src/i18n/locales/en.ts

@ -427,6 +427,7 @@ export default {
Zaps: 'Zaps', Zaps: 'Zaps',
Boosts: 'Boosts', Boosts: 'Boosts',
Badges: 'Badges', Badges: 'Badges',
Reports: 'Reports',
'Enjoying Jumble?': 'Enjoying Jumble?', 'Enjoying Jumble?': 'Enjoying Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊': 'Your donation helps me maintain Jumble and make it better! 😊':
'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', Bookmarks: 'Bookmarks',
'Follow Packs': 'Follow Packs', 'Follow Packs': 'Follow Packs',
'Follow Pack': 'Follow Pack', 'Follow Pack': 'Follow Pack',
'Follow pack by': 'by',
'In Follow Packs': 'In Follow Packs', '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', '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',

35
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
}
}

28
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<string[]> {
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<string>()
for (const ev of events) {
if (profileBadgesEventReferencesA(ev, badgeATag)) authors.add(ev.pubkey)
}
return [...authors]
}

27
src/lib/profile-report-relay-urls.ts

@ -0,0 +1,27 @@
/**
* Relays for profile NIP-56 reports (kind 1984): only the viewers 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<string[]> {
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
})
}
Loading…
Cancel
Save