From 3b485409fb799ef097c06cb54b21af61383d7ffc Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 26 Mar 2026 10:25:21 +0100 Subject: [PATCH] bug-fixes --- .../ContentPreview/FollowPackPreview.tsx | 33 ++- .../RelayPulseActiveNpubsSheet.tsx | 16 +- src/components/FollowButton/index.tsx | 10 +- .../Profile/ProfileBadgeDetailDialog.tsx | 176 ++++++++++++++ .../Profile/ProfileHeaderInteractions.tsx | 222 +++++++++++++++--- .../Profile/ProfileInteractionsAccordion.tsx | 29 ++- src/components/Profile/index.tsx | 16 +- src/constants.ts | 2 + src/hooks/useProfileBadges.tsx | 19 +- src/hooks/useProfileInteractions.tsx | 92 ++++++-- src/hooks/useProfileReports.tsx | 72 ++++++ src/i18n/locales/de.ts | 8 + src/i18n/locales/en.ts | 8 + src/lib/badge-definition-media.ts | 35 +++ src/lib/fetch-badge-recipient-pubkeys.ts | 28 +++ src/lib/profile-report-relay-urls.ts | 27 +++ 16 files changed, 696 insertions(+), 97 deletions(-) create mode 100644 src/components/Profile/ProfileBadgeDetailDialog.tsx create mode 100644 src/hooks/useProfileReports.tsx create mode 100644 src/lib/badge-definition-media.ts create mode 100644 src/lib/fetch-badge-recipient-pubkeys.ts create mode 100644 src/lib/profile-report-relay-urls.ts diff --git a/src/components/ContentPreview/FollowPackPreview.tsx b/src/components/ContentPreview/FollowPackPreview.tsx index 725ff92d..f5851c5b 100644 --- a/src/components/ContentPreview/FollowPackPreview.tsx +++ b/src/components/ContentPreview/FollowPackPreview.tsx @@ -1,7 +1,7 @@ import { getPubkeysFromPTags } from '@/lib/tag' import logger from '@/lib/logger' import { cn } from '@/lib/utils' -import { useFollowList } from '@/providers/FollowListProvider' +import { useFollowListOptional } from '@/providers/FollowListProvider' import { useMuteList } from '@/contexts/mute-list-context' import { useNostr } from '@/providers/NostrProvider' import { Event } from 'nostr-tools' @@ -9,8 +9,9 @@ import { Users } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { SimpleUserAvatar } from '../UserAvatar' -import { Button } from '../ui/button' +import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar' +import Username from '@/components/Username' +import { Button } from '@/components/ui/button' export default function FollowPackPreview({ event, @@ -21,7 +22,8 @@ export default function FollowPackPreview({ }) { const { t } = useTranslation() const { pubkey } = useNostr() - const { followings, follow } = useFollowList() + const followList = useFollowListOptional() + const followings = followList?.followings ?? [] const { mutePubkeySet } = useMuteList() const [busy, setBusy] = useState(false) @@ -56,6 +58,8 @@ export default function FollowPackPreview({ toast.error(t('Please log in to follow')) return } + if (!followList) return + const { follow } = followList const toFollow = packPubkeys.filter((p) => !followingSet.has(p) && !mutePubkeySet.has(p)) if (toFollow.length === 0) { const mutedCount = packPubkeys.filter((p) => mutePubkeySet.has(p) && !followingSet.has(p)).length @@ -79,14 +83,25 @@ export default function FollowPackPreview({ setBusy(false) } }, - [pubkey, packPubkeys, followingSet, mutePubkeySet, follow, t] + [pubkey, followList, packPubkeys, followingSet, mutePubkeySet, t] ) return (
-
- [{t('Follow Pack')}] - {title} +
+
+ [{t('Follow Pack')}] + {title} +
+
+ {t('Follow pack by')}: + + +
{description ? ( @@ -120,7 +135,7 @@ export default function FollowPackPreview({ {!pubkey ? (

{t('Please log in to follow')}

- ) : ( + ) : !followList ? null : ( +
+ ) : null} + +
+
{t('Other recipients')}
+ {recipientsLoading ? ( +
{t('Loading...')}
+ ) : recipientsError ? ( +
{t('Recipients could not be loaded')}
+ ) : otherRecipients.length === 0 ? ( +
{t('No other recipients found')}
+ ) : ( + +
    + {otherRecipients.map((pk) => ( +
  • + +
  • + ))} +
+
+ )} +
+ + + + + ) +} 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 ( -
- {label} { - 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 + }) +}