diff --git a/src/components/Profile/ProfileReportsFeed.tsx b/src/components/Profile/ProfileReportsFeed.tsx
new file mode 100644
index 00000000..4852f4f7
--- /dev/null
+++ b/src/components/Profile/ProfileReportsFeed.tsx
@@ -0,0 +1,94 @@
+import NoteCard from '@/components/NoteCard'
+import { Skeleton } from '@/components/ui/skeleton'
+import { useProfileReportsEvents } from '@/hooks/useProfileReportsEvents'
+import { useProfileReportsRelayBuilder } from '@/hooks/useProfileReportsRelayBuilder'
+import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { RefreshCw } from 'lucide-react'
+
+const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => {
+ const { t } = useTranslation()
+ const relayUrlsBuilder = useProfileReportsRelayBuilder(pubkey)
+ const { received, made, isLoading, refresh } = useProfileReportsEvents({
+ pubkey,
+ relayUrlsBuilder
+ })
+ const [isRefreshing, setIsRefreshing] = useState(false)
+
+ useEffect(() => {
+ if (!isLoading) setIsRefreshing(false)
+ }, [isLoading])
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ refresh: () => {
+ setIsRefreshing(true)
+ refresh()
+ }
+ }),
+ [refresh]
+ )
+
+ if (isLoading && received.length === 0 && made.length === 0) {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ {isRefreshing && (
+
+
+ {t('Refreshing reports...')}
+
+ )}
+
+
+
+ {t('Reports received')}
+
+ {received.length === 0 ? (
+ {t('No reports received')}
+ ) : (
+
+ {received.map((event) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {t('Reports made')}
+
+ {made.length === 0 ? (
+ {t('No reports made')}
+ ) : (
+
+ {made.map((event) => (
+
+ ))}
+
+ )}
+
+
+ )
+})
+
+ProfileReportsFeed.displayName = 'ProfileReportsFeed'
+
+export default ProfileReportsFeed
diff --git a/src/components/Profile/ProfileTimeline.tsx b/src/components/Profile/ProfileTimeline.tsx
index 51db841a..923231a7 100644
--- a/src/components/Profile/ProfileTimeline.tsx
+++ b/src/components/Profile/ProfileTimeline.tsx
@@ -4,7 +4,7 @@ import { RefreshCw } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef } from 'react'
-import { useProfileTimeline } from '@/hooks/useProfileTimeline'
+import { useProfileTimeline, type ProfileTimelineRelayUrlsBuilder } from '@/hooks/useProfileTimeline'
const INITIAL_SHOW_COUNT = 25
const LOAD_MORE_COUNT = 25
@@ -18,6 +18,7 @@ interface ProfileTimelineProps {
kinds: number[]
cacheKey: string
filterPredicate?: (event: Event) => boolean
+ relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder
getKindLabel: (kindValue: string) => string
refreshLabel: string
emptyLabel: string
@@ -38,6 +39,7 @@ const ProfileTimeline = forwardRef<
kinds: timelineKinds,
cacheKey,
filterPredicate,
+ relayUrlsBuilder,
getKindLabel,
refreshLabel,
emptyLabel,
@@ -54,7 +56,8 @@ const ProfileTimeline = forwardRef<
cacheKey,
kinds: timelineKinds,
limit: 200,
- filterPredicate
+ filterPredicate,
+ relayUrlsBuilder
})
useEffect(() => {
diff --git a/src/components/Profile/ProfileWallFeed.tsx b/src/components/Profile/ProfileWallFeed.tsx
new file mode 100644
index 00000000..742c74da
--- /dev/null
+++ b/src/components/Profile/ProfileWallFeed.tsx
@@ -0,0 +1,117 @@
+import NoteCard from '@/components/NoteCard'
+import { Skeleton } from '@/components/ui/skeleton'
+import { useProfileWall } from '@/hooks/useProfileWall'
+import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { RefreshCw } from 'lucide-react'
+
+type ProfileWallFeedProps = {
+ pubkey: string
+ profileEventId?: string
+}
+
+const ProfileWallFeed = forwardRef<{ refresh: () => void }, ProfileWallFeedProps>(
+ ({ pubkey, profileEventId }, ref) => {
+ const { t } = useTranslation()
+ const { badges, comments, isLoading, refresh } = useProfileWall(pubkey, profileEventId)
+ const [isRefreshing, setIsRefreshing] = useState(false)
+
+ useEffect(() => {
+ if (!isLoading) setIsRefreshing(false)
+ }, [isLoading])
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ refresh: () => {
+ setIsRefreshing(true)
+ refresh()
+ }
+ }),
+ [refresh]
+ )
+
+ if (isLoading && badges.length === 0 && comments.length === 0) {
+ return (
+
+
+
+
+
+ {Array.from({ length: 2 }).map((_, i) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ {isRefreshing && (
+
+
+ {t('Refreshing wall...')}
+
+ )}
+
+ {badges.length > 0 && (
+
+
+ {badges.map((badge) => (
+
+ {badge.imageUrl ? (
+

+ ) : (
+
+ {badge.name}
+
+ )}
+
+ {badge.name}
+
+
+ ))}
+
+
+ )}
+
+
+
+ {!profileEventId ? (
+ {t('Profile metadata not loaded yet')}
+ ) : comments.length === 0 ? (
+ {t('No wall comments yet')}
+ ) : (
+
+ {comments.map((event) => (
+
+ ))}
+
+ )}
+
+
+ )
+ }
+)
+
+ProfileWallFeed.displayName = 'ProfileWallFeed'
+
+export default ProfileWallFeed
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 624c3091..a3a5f404 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -66,6 +66,8 @@ import ProfileFeedWithPins from './ProfileFeedWithPins'
import ProfileLikedFeed from './ProfileLikedFeed'
import ProfileMediaFeed from './ProfileMediaFeed'
import ProfilePublicationsFeed from './ProfilePublicationsFeed'
+import ProfileReportsFeed from './ProfileReportsFeed'
+import ProfileWallFeed from './ProfileWallFeed'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { TNoteListRef } from '@/components/NoteList'
import SmartFollowings from './SmartFollowings'
@@ -240,8 +242,12 @@ export default function Profile({
const postsFeedRef = useRef<{ refresh: () => void }>(null)
const mediaFeedRef = useRef(null)
const publicationsFeedRef = useRef<{ refresh: () => void }>(null)
+ const reportsFeedRef = useRef<{ refresh: () => void }>(null)
+ const wallFeedRef = useRef<{ refresh: () => void }>(null)
const likedFeedRef = useRef<{ refresh: () => void }>(null)
- const [profileFeedTab, setProfileFeedTab] = useState<'posts' | 'media' | 'publications' | 'liked'>('posts')
+ const [profileFeedTab, setProfileFeedTab] = useState<
+ 'posts' | 'media' | 'publications' | 'reports' | 'wall' | 'liked'
+ >('posts')
/** Bumped after profile-view relay sync so payment + kind-0 JSON re-query storage and relays. */
const [authorReplaceablesSyncGen, setAuthorReplaceablesSyncGen] = useState(0)
const profilePubkeyRef = useRef(null)
@@ -474,6 +480,10 @@ export default function Profile({
mediaFeedRef.current?.refresh()
} else if (profileFeedTab === 'publications') {
publicationsFeedRef.current?.refresh()
+ } else if (profileFeedTab === 'reports') {
+ reportsFeedRef.current?.refresh()
+ } else if (profileFeedTab === 'wall') {
+ wallFeedRef.current?.refresh()
} else if (profileFeedTab === 'liked') {
likedFeedRef.current?.refresh()
}
@@ -807,7 +817,14 @@ export default function Profile({
{
- if (v === 'posts' || v === 'media' || v === 'publications' || (isSelf && v === 'liked')) {
+ if (
+ v === 'posts' ||
+ v === 'media' ||
+ v === 'publications' ||
+ v === 'reports' ||
+ v === 'wall' ||
+ (isSelf && v === 'liked')
+ ) {
setProfileFeedTab(v)
}
}}
@@ -826,6 +843,12 @@ export default function Profile({
>
{t('Articles and Publications')}
+
+ {t('Reports')}
+
+
+ {t('Wall')}
+
{isSelf && (
{t('Liked')}
@@ -841,6 +864,12 @@ export default function Profile({
+
+
+
+
+
+
{isSelf && (
diff --git a/src/constants.ts b/src/constants.ts
index 384d8c4b..fc9d52b7 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -577,8 +577,10 @@ export const ExtendedKind = {
CALENDAR_EVENT_RSVP: 31925,
/** NIP-A7 Spells: portable relay query filters (kind 777) */
SPELL: 777,
- /** NIP-58 Badges: profile badges list (addressable, d=profile_badges) */
+ /** NIP-58 Badge set (addressable, NIP-51 set). Legacy profile list used d=profile_badges on this kind. */
PROFILE_BADGES: 30008,
+ /** NIP-58 Profile Badges display list (NIP-51 replaceable list, current format). */
+ PROFILE_BADGES_LIST: 10008,
/** NIP-58 Badges: badge definition (addressable) */
BADGE_DEFINITION: 30009,
/** Web page bookmark (URL in i/I or r tags); used in RSS+Web relay discovery */
diff --git a/src/hooks/useProfileReportsEvents.tsx b/src/hooks/useProfileReportsEvents.tsx
new file mode 100644
index 00000000..3bade308
--- /dev/null
+++ b/src/hooks/useProfileReportsEvents.tsx
@@ -0,0 +1,323 @@
+import { ExtendedKind } from '@/constants'
+import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
+import type { ProfileTimelineRelayUrlsBuilder } from '@/hooks/useProfileTimeline'
+import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
+import { isNip56ReportEvent } from '@/lib/event'
+import { isReportAuthoredBy, reportTargetsPubkey } from '@/lib/nip56-reports'
+import { normalizeHexPubkey } from '@/lib/pubkey'
+import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
+import { useDeletedEvent } from '@/providers/DeletedEventProvider'
+import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+import { useNostrOptional } from '@/providers/nostr-context'
+import client from '@/services/client.service'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Event, kinds, type Filter } from 'nostr-tools'
+
+const REPORT_KINDS = [kinds.Report, ExtendedKind.REPORT] as const
+const CACHE_DURATION = 5 * 60 * 1000
+
+type CacheEntry = { events: Event[]; lastUpdated: number }
+const memoryByKey = new Map()
+
+function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string {
+ const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001')
+ const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001')
+ return `${fav}\u0000${blk}`
+}
+
+function mergeReportEvents(
+ raw: Event[],
+ limit: number,
+ isEventDeleted: (e: Event) => boolean,
+ extraFilter?: (e: Event) => boolean
+): Event[] {
+ const dedup = new Map()
+ for (const e of raw) {
+ if (!isNip56ReportEvent(e)) continue
+ if (extraFilter && !extraFilter(e)) continue
+ if (isEventDeleted(e)) continue
+ dedup.set(e.id, e)
+ }
+ return [...dedup.values()].sort((a, b) => b.created_at - a.created_at).slice(0, limit)
+}
+
+type FetchMode = 'received' | 'made'
+
+function buildFilter(pubkey: string, mode: FetchMode, limit: number): Filter {
+ if (mode === 'made') {
+ return { authors: [pubkey], kinds: [...REPORT_KINDS], limit }
+ }
+ return { kinds: [...REPORT_KINDS], '#p': [pubkey], limit }
+}
+
+function postFilter(pubkey: string, mode: FetchMode) {
+ return mode === 'made'
+ ? (e: Event) => isReportAuthoredBy(e, pubkey)
+ : (e: Event) => reportTargetsPubkey(e, pubkey)
+}
+
+type UseProfileReportsEventsOptions = {
+ pubkey: string
+ relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder
+ limit?: number
+}
+
+export function useProfileReportsEvents({
+ pubkey,
+ relayUrlsBuilder,
+ limit = 200
+}: UseProfileReportsEventsOptions) {
+ const { favoriteRelays, blockedRelays } = useFavoriteRelays()
+ const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
+ const nostr = useNostrOptional()
+ const { isEventDeleted, tombstoneEpoch } = useDeletedEvent()
+ const isEventDeletedRef = useRef(isEventDeleted)
+ isEventDeletedRef.current = isEventDeleted
+
+ const receivedCacheKey = useMemo(() => `${pubkey}-profile-reports-received-v1`, [pubkey])
+ const madeCacheKey = useMemo(() => `${pubkey}-profile-reports-made-v1`, [pubkey])
+
+ const receivedCached = memoryByKey.get(receivedCacheKey)
+ const madeCached = memoryByKey.get(madeCacheKey)
+
+ const [received, setReceived] = useState(receivedCached?.events ?? [])
+ const [made, setMade] = useState(madeCached?.events ?? [])
+ const [isLoading, setIsLoading] = useState(!receivedCached || !madeCached)
+ const [refreshToken, setRefreshToken] = useState(0)
+
+ const includeAuthorLocalRelays = useMemo(() => {
+ const me = nostr?.pubkey?.trim()
+ if (!me) return false
+ try {
+ return normalizeHexPubkey(me) === normalizeHexPubkey(pubkey)
+ } catch {
+ return false
+ }
+ }, [nostr?.pubkey, pubkey])
+
+ const relayListsKey = useMemo(
+ () => relayListsContentKey(favoriteRelays, blockedRelays),
+ [favoriteRelays, blockedRelays]
+ )
+
+ const relayUrlsBuilderRef = useRef(relayUrlsBuilder)
+ relayUrlsBuilderRef.current = relayUrlsBuilder
+
+ const resolveFeedUrls = useCallback(
+ (
+ authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
+ includeAuthorLocal: boolean
+ ) => {
+ const custom = relayUrlsBuilderRef.current
+ if (custom) {
+ return custom(favoriteRelays, blockedRelays, authorRelayList, includeAuthorLocal)
+ }
+ return buildProfilePageReadRelayUrls(
+ favoriteRelays,
+ blockedRelays,
+ authorRelayList,
+ false,
+ includeAuthorLocal,
+ [...REPORT_KINDS],
+ useGlobalRelayBootstrap
+ )
+ },
+ [favoriteRelays, blockedRelays, useGlobalRelayBootstrap]
+ )
+
+ useEffect(() => {
+ setReceived((prev) => {
+ const next = prev.filter((e) => !isEventDeletedRef.current(e))
+ const c = memoryByKey.get(receivedCacheKey)
+ if (c) memoryByKey.set(receivedCacheKey, { events: next, lastUpdated: c.lastUpdated })
+ return next
+ })
+ setMade((prev) => {
+ const next = prev.filter((e) => !isEventDeletedRef.current(e))
+ const c = memoryByKey.get(madeCacheKey)
+ if (c) memoryByKey.set(madeCacheKey, { events: next, lastUpdated: c.lastUpdated })
+ return next
+ })
+ }, [tombstoneEpoch, receivedCacheKey, madeCacheKey])
+
+ useEffect(() => {
+ let cancelled = false
+ const closers: (() => void)[] = []
+
+ const loadMode = async (
+ mode: FetchMode,
+ cacheKey: string,
+ setEvents: (events: Event[]) => void
+ ) => {
+ const mem = memoryByKey.get(cacheKey)
+ const cacheAge = mem ? Date.now() - mem.lastUpdated : Infinity
+ const isCacheFresh = cacheAge < CACHE_DURATION
+ const pool = new Map()
+ if (isCacheFresh && mem) {
+ mem.events.forEach((e) => pool.set(e.id, e))
+ }
+
+ const flush = () => {
+ if (cancelled) return
+ const processed = mergeReportEvents(
+ Array.from(pool.values()),
+ limit,
+ isEventDeletedRef.current,
+ postFilter(pubkey, mode)
+ )
+ memoryByKey.set(cacheKey, { events: processed, lastUpdated: Date.now() })
+ setEvents(processed)
+ }
+
+ let pkNorm = pubkey
+ try {
+ pkNorm = normalizeHexPubkey(pubkey)
+ } catch {
+ /* use raw */
+ }
+
+ const emptyAuthor = { read: [] as string[], write: [] as string[], httpRead: [] as string[], httpWrite: [] as string[] }
+ const provisionalUrls = resolveFeedUrls(emptyAuthor, includeAuthorLocalRelays)
+ if (provisionalUrls.length === 0) return
+
+ const filter = buildFilter(pkNorm, mode, limit)
+ const subRequests = [{ urls: provisionalUrls, filter }]
+
+ try {
+ const disk = await client.getLocalFeedEvents(subRequests)
+ if (!cancelled) {
+ for (const e of disk) pool.set(e.id, e)
+ flush()
+ }
+ } catch {
+ /* best-effort */
+ }
+
+ try {
+ const fetched = await client.fetchEvents(provisionalUrls, filter, {
+ cache: true,
+ eoseTimeout: 4500,
+ globalTimeout: 14_000
+ })
+ if (!cancelled) {
+ for (const e of fetched) pool.set(e.id, e)
+ flush()
+ }
+ } catch {
+ /* ignore */
+ }
+
+ try {
+ const { closer } = await client.subscribeTimeline(
+ subRequests,
+ {
+ onEvents: (rows) => {
+ if (cancelled) return
+ for (const e of rows as Event[]) pool.set(e.id, e)
+ flush()
+ },
+ onNew: (evt) => {
+ if (cancelled) return
+ pool.set((evt as Event).id, evt as Event)
+ flush()
+ }
+ },
+ { needSort: true }
+ )
+ closers.push(closer)
+ } catch {
+ /* ignore */
+ }
+
+ const authorRl = await client.fetchRelayList(pubkey).catch(() => emptyAuthor)
+ if (cancelled) return
+ const fullUrls = resolveFeedUrls(authorRl, includeAuthorLocalRelays)
+ const deltaUrls = subtractNormalizedRelayUrls(fullUrls, provisionalUrls)
+ if (deltaUrls.length === 0) return
+
+ const deltaRequests = [{ urls: deltaUrls, filter }]
+ try {
+ const diskDelta = await client.getLocalFeedEvents(deltaRequests)
+ if (!cancelled) {
+ for (const e of diskDelta) pool.set(e.id, e)
+ flush()
+ }
+ } catch {
+ /* ignore */
+ }
+ try {
+ const { closer } = await client.subscribeTimeline(
+ deltaRequests,
+ {
+ onEvents: (rows) => {
+ if (cancelled) return
+ for (const e of rows as Event[]) pool.set(e.id, e)
+ flush()
+ },
+ onNew: (evt) => {
+ if (cancelled) return
+ pool.set((evt as Event).id, evt as Event)
+ flush()
+ }
+ },
+ { needSort: true }
+ )
+ closers.push(closer)
+ } catch {
+ /* ignore */
+ }
+ }
+
+ const run = async () => {
+ const recvMem = memoryByKey.get(receivedCacheKey)
+ const madeMem = memoryByKey.get(madeCacheKey)
+ const recvFresh = recvMem && Date.now() - recvMem.lastUpdated < CACHE_DURATION
+ const madeFresh = madeMem && Date.now() - madeMem.lastUpdated < CACHE_DURATION
+
+ if (recvFresh && recvMem) {
+ setReceived(recvMem.events)
+ }
+ if (madeFresh && madeMem) {
+ setMade(madeMem.events)
+ }
+ if (recvFresh && madeFresh) {
+ setIsLoading(false)
+ if (refreshToken === 0) return
+ } else {
+ setIsLoading(true)
+ }
+
+ await Promise.all([
+ loadMode('received', receivedCacheKey, setReceived),
+ loadMode('made', madeCacheKey, setMade)
+ ])
+
+ if (!cancelled) setIsLoading(false)
+ }
+
+ void run()
+
+ return () => {
+ cancelled = true
+ closers.forEach((c) => c())
+ }
+ }, [
+ pubkey,
+ receivedCacheKey,
+ madeCacheKey,
+ limit,
+ refreshToken,
+ relayListsKey,
+ includeAuthorLocalRelays,
+ resolveFeedUrls
+ ])
+
+ const refresh = useCallback(() => {
+ memoryByKey.delete(receivedCacheKey)
+ memoryByKey.delete(madeCacheKey)
+ setIsLoading(true)
+ setRefreshToken((t) => t + 1)
+ }, [receivedCacheKey, madeCacheKey])
+
+ return { received, made, isLoading, refresh }
+}
diff --git a/src/hooks/useProfileReportsRelayBuilder.tsx b/src/hooks/useProfileReportsRelayBuilder.tsx
new file mode 100644
index 00000000..7a29c23f
--- /dev/null
+++ b/src/hooks/useProfileReportsRelayBuilder.tsx
@@ -0,0 +1,45 @@
+import { useNostrOptional } from '@/providers/nostr-context'
+import { getCacheRelayUrls } from '@/lib/private-relays'
+import { buildProfileReportsRelayUrls } from '@/lib/profile-reports-relays'
+import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import type { ProfileTimelineRelayUrlsBuilder } from '@/hooks/useProfileTimeline'
+
+/** Relay list builder for the profile Reports tab (inbox + HTTP index + cache when viewing own profile). */
+export function useProfileReportsRelayBuilder(pubkey: string): ProfileTimelineRelayUrlsBuilder {
+ const nostr = useNostrOptional()
+ const [cacheRelayUrls, setCacheRelayUrls] = useState([])
+
+ const isSelf = useMemo(() => {
+ const me = nostr?.pubkey?.trim()
+ if (!me) return false
+ try {
+ return hexPubkeysEqual(normalizeHexPubkey(me), normalizeHexPubkey(pubkey))
+ } catch {
+ return false
+ }
+ }, [nostr?.pubkey, pubkey])
+
+ useEffect(() => {
+ if (!isSelf || !nostr?.pubkey?.trim()) {
+ setCacheRelayUrls([])
+ return
+ }
+ let cancelled = false
+ void getCacheRelayUrls(nostr.pubkey).then((urls) => {
+ if (!cancelled) setCacheRelayUrls(urls)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [isSelf, nostr?.pubkey])
+
+ return useCallback(
+ (_favoriteRelays, blocked, authorRelayList, includeAuthorLocalRelays) =>
+ buildProfileReportsRelayUrls(authorRelayList, blocked, {
+ includeAuthorLocalRelays,
+ cacheRelayUrls: isSelf ? cacheRelayUrls : []
+ }),
+ [cacheRelayUrls, isSelf]
+ )
+}
diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx
index 0c687782..7b29d447 100644
--- a/src/hooks/useProfileTimeline.tsx
+++ b/src/hooks/useProfileTimeline.tsx
@@ -5,6 +5,7 @@ import { Event, kinds as nostrKinds, type Filter } from 'nostr-tools'
import { CALENDAR_EVENT_KINDS, ExtendedKind, isDocumentRelayKind, isSocialKindBlockedKind } from '@/constants'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
+import type { ProfileReportsRelayList } from '@/lib/profile-reports-relays'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@@ -21,12 +22,21 @@ type ProfileTimelineMemoryEntry = {
const memoryTimelineByKey = new Map()
const CACHE_DURATION = 5 * 60 * 1000
+export type ProfileTimelineRelayUrlsBuilder = (
+ favoriteRelays: string[],
+ blockedRelays: string[],
+ authorRelayList: ProfileReportsRelayList,
+ includeAuthorLocalRelays: boolean
+) => string[]
+
type UseProfileTimelineOptions = {
pubkey: string
cacheKey: string
kinds: number[]
limit?: number
filterPredicate?: (event: Event) => boolean
+ /** When set, replaces {@link buildProfilePageReadRelayUrls} (e.g. profile Reports tab inboxes only). */
+ relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder
}
type UseProfileTimelineResult = {
@@ -127,7 +137,8 @@ export function useProfileTimeline({
cacheKey,
kinds,
limit = 200,
- filterPredicate
+ filterPredicate,
+ relayUrlsBuilder
}: UseProfileTimelineOptions): UseProfileTimelineResult {
const nostr = useNostrOptional()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
@@ -151,9 +162,38 @@ export function useProfileTimeline({
const filterPredicateRef = useRef(filterPredicate)
filterPredicateRef.current = filterPredicate
+ const relayUrlsBuilderRef = useRef(relayUrlsBuilder)
+ relayUrlsBuilderRef.current = relayUrlsBuilder
const limitRef = useRef(limit)
limitRef.current = limit
+ const resolveFeedUrls = useCallback(
+ (
+ favoriteRelaysArg: string[],
+ blockedRelaysArg: string[],
+ authorRelayList: ProfileReportsRelayList,
+ includeAuthorLocalRelaysArg: boolean,
+ kindsArg: number[],
+ useGlobalRelayBootstrapArg: boolean
+ ) => {
+ const custom = relayUrlsBuilderRef.current
+ if (custom) {
+ return custom(favoriteRelaysArg, blockedRelaysArg, authorRelayList, includeAuthorLocalRelaysArg)
+ }
+ const socialKinds = kindsArg.some(isSocialKindBlockedKind)
+ return buildProfilePageReadRelayUrls(
+ favoriteRelaysArg,
+ blockedRelaysArg,
+ authorRelayList as { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
+ socialKinds,
+ includeAuthorLocalRelaysArg,
+ kindsArg,
+ useGlobalRelayBootstrapArg
+ )
+ },
+ []
+ )
+
const cachedEntry = useMemo(() => memoryTimelineByKey.get(cacheKey), [cacheKey])
const [events, setEvents] = useState(cachedEntry?.events ?? [])
const [isLoading, setIsLoading] = useState(!cachedEntry)
@@ -307,11 +347,10 @@ export function useProfileTimeline({
const authorRelayPromise = client.fetchRelayList(pubkey).catch(() => emptyAuthor)
- const provisionalFeedUrls = buildProfilePageReadRelayUrls(
+ const provisionalFeedUrls = resolveFeedUrls(
favoriteRelays,
blockedRelays,
emptyAuthor,
- socialKinds,
includeAuthorLocalRelays,
kinds,
useGlobalRelayBootstrap
@@ -403,11 +442,10 @@ export function useProfileTimeline({
void (async () => {
const authorRl = await authorRelayPromise
if (cancelled) return
- const fullFeedUrls = buildProfilePageReadRelayUrls(
+ const fullFeedUrls = resolveFeedUrls(
favoriteRelays,
blockedRelays,
authorRl,
- socialKinds,
includeAuthorLocalRelays,
kinds,
useGlobalRelayBootstrap
@@ -443,7 +481,17 @@ export function useProfileTimeline({
subscriptionRef.current()
subscriptionRef.current = () => {}
}
- }, [pubkey, cacheKey, JSON.stringify(kinds), limit, refreshToken, relayListsKey, includeAuthorLocalRelays, useGlobalRelayBootstrap])
+ }, [
+ pubkey,
+ cacheKey,
+ JSON.stringify(kinds),
+ limit,
+ refreshToken,
+ relayListsKey,
+ includeAuthorLocalRelays,
+ useGlobalRelayBootstrap,
+ resolveFeedUrls
+ ])
const refresh = useCallback(() => {
subscriptionRef.current()
diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx
new file mode 100644
index 00000000..f8166df8
--- /dev/null
+++ b/src/hooks/useProfileWall.tsx
@@ -0,0 +1,179 @@
+import { ExtendedKind } from '@/constants'
+import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
+import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
+import { getReplaceableCoordinate } from '@/lib/event'
+import {
+ isNip58ProfileBadgesListEvent,
+ LEGACY_PROFILE_BADGES_D_TAG,
+ parseAddressableCoordinate,
+ parseProfileBadgeEntries,
+ resolveBadgeDisplayFromDefinition,
+ type ResolvedProfileBadge
+} from '@/lib/nip58-profile-badges'
+import { isDirectProfileWallComment } from '@/lib/profile-wall-comments'
+import { normalizeHexPubkey } from '@/lib/pubkey'
+import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+import { useDeletedEvent } from '@/providers/DeletedEventProvider'
+import client, { replaceableEventService } from '@/services/client.service'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Event, kinds, type Filter } from 'nostr-tools'
+
+const CACHE_DURATION = 5 * 60 * 1000
+const wallCacheByKey = new Map()
+
+export function useProfileWall(pubkey: string, profileEventId: string | undefined) {
+ const { favoriteRelays, blockedRelays } = useFavoriteRelays()
+ const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
+ const { isEventDeleted } = useDeletedEvent()
+ const isEventDeletedRef = useRef(isEventDeleted)
+ isEventDeletedRef.current = isEventDeleted
+
+ const cacheKey = useMemo(() => `${pubkey}-profile-wall-v1`, [pubkey])
+ const cached = wallCacheByKey.get(cacheKey)
+
+ const [badges, setBadges] = useState(cached?.badges ?? [])
+ const [comments, setComments] = useState(cached?.comments ?? [])
+ const [isLoading, setIsLoading] = useState(!cached)
+ const [refreshToken, setRefreshToken] = useState(0)
+
+ useEffect(() => {
+ let cancelled = false
+
+ const run = async () => {
+ const mem = wallCacheByKey.get(cacheKey)
+ if (mem && Date.now() - mem.lastUpdated < CACHE_DURATION && refreshToken === 0) {
+ setBadges(mem.badges)
+ setComments(mem.comments)
+ setIsLoading(false)
+ return
+ }
+
+ setIsLoading(true)
+
+ let pkNorm = pubkey
+ try {
+ pkNorm = normalizeHexPubkey(pubkey)
+ } catch {
+ /* use raw */
+ }
+
+ const emptyAuthor = { read: [] as string[], write: [] as string[], httpRead: [] as string[], httpWrite: [] as string[] }
+ const authorRl = await client.fetchRelayList(pubkey).catch(() => emptyAuthor)
+ if (cancelled) return
+
+ const relayUrls = buildProfilePageReadRelayUrls(
+ favoriteRelays,
+ blockedRelays,
+ authorRl,
+ false,
+ false,
+ [ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION],
+ useGlobalRelayBootstrap
+ )
+
+ // --- Badges (NIP-58) ---
+ let listEvent =
+ (await replaceableEventService.fetchReplaceableEvent(pkNorm, ExtendedKind.PROFILE_BADGES_LIST)) ??
+ undefined
+ if (!listEvent || !isNip58ProfileBadgesListEvent(listEvent)) {
+ const legacy = await replaceableEventService.fetchReplaceableEvent(
+ pkNorm,
+ ExtendedKind.PROFILE_BADGES,
+ LEGACY_PROFILE_BADGES_D_TAG
+ )
+ if (legacy && isNip58ProfileBadgesListEvent(legacy)) listEvent = legacy
+ }
+
+ const entries = parseProfileBadgeEntries(listEvent)
+ const defCoords = [...new Set(entries.map((e) => e.definitionCoordinate))]
+ const defByCoord = new Map()
+
+ await Promise.all(
+ defCoords.map(async (coord) => {
+ const parsed = parseAddressableCoordinate(coord)
+ if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) {
+ defByCoord.set(coord, undefined)
+ return
+ }
+ const defEvent = await replaceableEventService.fetchReplaceableEvent(
+ parsed.pubkey,
+ parsed.kind,
+ parsed.d
+ )
+ defByCoord.set(coord, defEvent)
+ })
+ )
+
+ const resolvedBadges = entries.map((entry) =>
+ resolveBadgeDisplayFromDefinition(entry, defByCoord.get(entry.definitionCoordinate))
+ )
+
+ // --- Wall comments (kind 1111 on profile kind 0) ---
+ let wallComments: Event[] = []
+ const profileId = profileEventId?.trim().toLowerCase()
+ if (profileId && /^[0-9a-f]{64}$/.test(profileId) && relayUrls.length > 0) {
+ const profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '')
+ const filters: Filter[] = [
+ { kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 },
+ { kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 }
+ ]
+ const pool = new Map()
+ try {
+ const rows = await Promise.all(
+ filters.map((filter) =>
+ client.fetchEvents(relayUrls, filter, {
+ cache: true,
+ eoseTimeout: 4500,
+ globalTimeout: 14_000
+ })
+ )
+ )
+ for (const batch of rows) {
+ for (const e of batch) pool.set(e.id, e)
+ }
+ } catch {
+ /* ignore */
+ }
+
+ wallComments = [...pool.values()]
+ .filter(
+ (e) =>
+ !isEventDeletedRef.current(e) &&
+ isDirectProfileWallComment(e, profileId, pkNorm)
+ )
+ .sort((a, b) => b.created_at - a.created_at)
+ }
+
+ if (cancelled) return
+ setBadges(resolvedBadges)
+ setComments(wallComments)
+ wallCacheByKey.set(cacheKey, {
+ badges: resolvedBadges,
+ comments: wallComments,
+ lastUpdated: Date.now()
+ })
+ setIsLoading(false)
+ }
+
+ void run()
+ return () => {
+ cancelled = true
+ }
+ }, [
+ pubkey,
+ profileEventId,
+ cacheKey,
+ refreshToken,
+ favoriteRelays,
+ blockedRelays,
+ useGlobalRelayBootstrap
+ ])
+
+ const refresh = useCallback(() => {
+ wallCacheByKey.delete(cacheKey)
+ setIsLoading(true)
+ setRefreshToken((t) => t + 1)
+ }, [cacheKey])
+
+ return { badges, comments, isLoading, refresh }
+}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 9617ee26..6d24a1be 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -797,6 +797,17 @@ export default {
"Search articles...": "Artikel suchen…",
"Refreshing articles...": "Artikel werden aktualisiert…",
"No articles or publications found": "Keine Artikel oder Veröffentlichungen gefunden",
+ "No reports found": "Keine Meldungen gefunden",
+ "No reports match your search": "Keine Meldungen passen zur Suche",
+ "Refreshing reports...": "Meldungen werden aktualisiert…",
+ "Reports received": "Erhaltene Meldungen",
+ "Reports made": "Abgegebene Meldungen",
+ "No reports received": "Keine erhaltenen Meldungen",
+ "No reports made": "Keine abgegebenen Meldungen",
+ "Wall": "Pinnwand",
+ "Refreshing wall...": "Pinnwand wird aktualisiert…",
+ "No wall comments yet": "Noch keine Pinnwand-Kommentare",
+ "Profile metadata not loaded yet": "Profil-Metadaten noch nicht geladen",
"No articles or publications match your search": "Keine Artikel oder Veröffentlichungen entsprechen der Suche",
"articles and publications": "Artikel und Veröffentlichungen",
Interests: "Interessen",
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index ba981555..6c8c47c4 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -832,6 +832,17 @@ export default {
"Refreshing articles...": "Refreshing articles...",
"No articles or publications found": "No articles or publications found",
"No articles or publications match your search": "No articles or publications match your search",
+ "No reports found": "No reports found",
+ "No reports match your search": "No reports match your search",
+ "Refreshing reports...": "Refreshing reports...",
+ "Reports received": "Reports received",
+ "Reports made": "Reports made",
+ "No reports received": "No reports received",
+ "No reports made": "No reports made",
+ "Wall": "Wall",
+ "Refreshing wall...": "Refreshing wall...",
+ "No wall comments yet": "No wall comments yet",
+ "Profile metadata not loaded yet": "Profile metadata not loaded yet",
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
diff --git a/src/lib/nip56-reports.ts b/src/lib/nip56-reports.ts
new file mode 100644
index 00000000..68d3f795
--- /dev/null
+++ b/src/lib/nip56-reports.ts
@@ -0,0 +1,33 @@
+import { isNip56ReportEvent } from '@/lib/event'
+import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
+import { Event } from 'nostr-tools'
+
+/** NIP-56: report targets this pubkey via a `p` tag. */
+export function reportTargetsPubkey(event: Event, pubkey: string): boolean {
+ if (!isNip56ReportEvent(event)) return false
+ let pkNorm: string
+ try {
+ pkNorm = normalizeHexPubkey(pubkey).toLowerCase()
+ } catch {
+ pkNorm = pubkey.trim().toLowerCase()
+ }
+ return event.tags.some((t) => {
+ if (t[0] !== 'p' && t[0] !== 'P') return false
+ if (typeof t[1] !== 'string') return false
+ try {
+ return hexPubkeysEqual(normalizeHexPubkey(t[1]), pkNorm)
+ } catch {
+ return t[1].trim().toLowerCase() === pkNorm
+ }
+ })
+}
+
+/** NIP-56: report published by this pubkey. */
+export function isReportAuthoredBy(event: Event, pubkey: string): boolean {
+ if (!isNip56ReportEvent(event)) return false
+ try {
+ return hexPubkeysEqual(normalizeHexPubkey(event.pubkey), normalizeHexPubkey(pubkey))
+ } catch {
+ return event.pubkey.trim().toLowerCase() === pubkey.trim().toLowerCase()
+ }
+}
diff --git a/src/lib/nip58-profile-badges.test.ts b/src/lib/nip58-profile-badges.test.ts
new file mode 100644
index 00000000..42e9325b
--- /dev/null
+++ b/src/lib/nip58-profile-badges.test.ts
@@ -0,0 +1,33 @@
+import { ExtendedKind } from '@/constants'
+import { describe, expect, it } from 'vitest'
+import { parseProfileBadgeEntries, parseAddressableCoordinate } from './nip58-profile-badges'
+import type { Event } from 'nostr-tools'
+
+describe('parseProfileBadgeEntries', () => {
+ it('pairs consecutive a and e tags', () => {
+ const event = {
+ kind: ExtendedKind.PROFILE_BADGES_LIST,
+ tags: [
+ ['a', '30009:alice:bravery'],
+ ['e', 'award1'],
+ ['a', '30009:alice:honor'],
+ ['e', 'award2'],
+ ['a', '30009:alice:orphan']
+ ]
+ } as Event
+ expect(parseProfileBadgeEntries(event)).toEqual([
+ { definitionCoordinate: '30009:alice:bravery', awardEventId: 'award1' },
+ { definitionCoordinate: '30009:alice:honor', awardEventId: 'award2' }
+ ])
+ })
+})
+
+describe('parseAddressableCoordinate', () => {
+ it('parses kind pubkey and d', () => {
+ expect(parseAddressableCoordinate('30009:alice:bravery')).toEqual({
+ kind: 30009,
+ pubkey: 'alice',
+ d: 'bravery'
+ })
+ })
+})
diff --git a/src/lib/nip58-profile-badges.ts b/src/lib/nip58-profile-badges.ts
new file mode 100644
index 00000000..0f445bb8
--- /dev/null
+++ b/src/lib/nip58-profile-badges.ts
@@ -0,0 +1,82 @@
+import { ExtendedKind } from '@/constants'
+import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media'
+import { tagNameEquals } from '@/lib/tag'
+import { Event } from 'nostr-tools'
+
+/** Legacy NIP-58 profile badges addressable `d` tag value. */
+export const LEGACY_PROFILE_BADGES_D_TAG = 'profile_badges'
+
+export type ProfileBadgeEntry = {
+ definitionCoordinate: string
+ awardEventId: string
+}
+
+export type ResolvedProfileBadge = {
+ definitionCoordinate: string
+ awardEventId: string
+ name: string
+ description?: string
+ imageUrl?: string
+}
+
+/** Parse consecutive `a` / `e` pairs from a NIP-58 profile badges list event. */
+export function parseProfileBadgeEntries(event: Event | undefined): ProfileBadgeEntry[] {
+ if (!event) return []
+ const out: ProfileBadgeEntry[] = []
+ const tags = event.tags
+ for (let i = 0; i < tags.length; i++) {
+ const t = tags[i]
+ if (t[0] !== 'a' || !t[1]?.trim()) continue
+ const next = tags[i + 1]
+ if (next?.[0] === 'e' && next[1]?.trim()) {
+ out.push({ definitionCoordinate: t[1].trim(), awardEventId: next[1].trim() })
+ i++
+ }
+ }
+ return out
+}
+
+export function isNip58ProfileBadgesListEvent(event: Event): boolean {
+ if (event.kind === ExtendedKind.PROFILE_BADGES_LIST) return true
+ if (event.kind !== ExtendedKind.PROFILE_BADGES) return false
+ const d = event.tags.find(tagNameEquals('d'))?.[1]?.trim()
+ return d === LEGACY_PROFILE_BADGES_D_TAG
+}
+
+export function parseAddressableCoordinate(
+ coordinate: string
+): { kind: number; pubkey: string; d: string } | null {
+ const trimmed = coordinate.trim()
+ const idx1 = trimmed.indexOf(':')
+ if (idx1 < 0) return null
+ const idx2 = trimmed.indexOf(':', idx1 + 1)
+ if (idx2 < 0) return null
+ const kind = parseInt(trimmed.slice(0, idx1), 10)
+ if (!Number.isFinite(kind)) return null
+ return {
+ kind,
+ pubkey: trimmed.slice(idx1 + 1, idx2),
+ d: trimmed.slice(idx2 + 1)
+ }
+}
+
+export function resolveBadgeDisplayFromDefinition(
+ entry: ProfileBadgeEntry,
+ defEvent: Event | undefined
+): ResolvedProfileBadge {
+ const parsed = parseAddressableCoordinate(entry.definitionCoordinate)
+ const fallbackName = parsed?.d || entry.definitionCoordinate
+ const name =
+ defEvent?.tags.find(tagNameEquals('name'))?.[1]?.trim() ||
+ defEvent?.tags.find(tagNameEquals('d'))?.[1]?.trim() ||
+ fallbackName
+ const description = defEvent?.tags.find(tagNameEquals('description'))?.[1]?.trim()
+ const media = extractBadgeDefinitionMedia(defEvent)
+ return {
+ definitionCoordinate: entry.definitionCoordinate,
+ awardEventId: entry.awardEventId,
+ name,
+ description: description || undefined,
+ imageUrl: media.image ?? media.thumb
+ }
+}
diff --git a/src/lib/profile-reports-relays.test.ts b/src/lib/profile-reports-relays.test.ts
new file mode 100644
index 00000000..15e18233
--- /dev/null
+++ b/src/lib/profile-reports-relays.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { buildProfileReportsRelayUrls } from './profile-reports-relays'
+
+describe('buildProfileReportsRelayUrls', () => {
+ it('uses inbox, http-index, and cache layers only', () => {
+ const urls = buildProfileReportsRelayUrls(
+ {
+ read: ['wss://inbox.example.com/'],
+ httpRead: ['https://index.example.com/'],
+ write: ['wss://outbox.example.com/']
+ },
+ [],
+ {
+ includeAuthorLocalRelays: true,
+ cacheRelayUrls: ['ws://127.0.0.1:4869/']
+ }
+ )
+ expect(urls.some((u) => u.includes('inbox.example.com'))).toBe(true)
+ expect(urls.some((u) => u.includes('index.example.com'))).toBe(true)
+ expect(urls.some((u) => u.includes('127.0.0.1'))).toBe(true)
+ expect(urls.some((u) => u.includes('outbox.example.com'))).toBe(false)
+ expect(urls.some((u) => u.includes('damus'))).toBe(false)
+ })
+})
diff --git a/src/lib/profile-reports-relays.ts b/src/lib/profile-reports-relays.ts
new file mode 100644
index 00000000..c3f8aa75
--- /dev/null
+++ b/src/lib/profile-reports-relays.ts
@@ -0,0 +1,53 @@
+import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
+import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority'
+import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
+import { normalizeAnyRelayUrl } from '@/lib/url'
+
+const PROFILE_REPORTS_MAX_RELAYS = 24
+
+export type ProfileReportsRelayList = {
+ read?: string[]
+ write?: string[]
+ httpRead?: string[]
+ httpWrite?: string[]
+}
+
+/**
+ * Profile Reports tab: subject's NIP-65 inboxes + HTTP index (`httpRead`), optional kind-10432 cache relays (own profile only).
+ * No favorites / fast-read widening — only the user's mailbox stack.
+ */
+export function buildProfileReportsRelayUrls(
+ authorRelayList: ProfileReportsRelayList,
+ blockedRelays: string[],
+ options: {
+ includeAuthorLocalRelays?: boolean
+ cacheRelayUrls?: readonly string[]
+ } = {}
+): string[] {
+ const blocked = new Set(
+ blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b).filter(Boolean)
+ )
+ const list = options.includeAuthorLocalRelays
+ ? authorRelayList
+ : stripMailboxLocalUrlsForRemoteViewers(authorRelayList)
+ const inboxLayer = relayUrlsLocalsFirst([...(list.httpRead ?? []), ...(list.read ?? [])])
+ const cacheLayer = relayUrlsLocalsFirst(
+ (options.cacheRelayUrls ?? []).filter((u) => {
+ const k = normalizeAnyRelayUrl(u) || u.trim()
+ return k.length > 0 && !blocked.has(k)
+ })
+ )
+ return feedRelayPolicyUrls(
+ [
+ { source: 'cache', urls: cacheLayer, explicit: true },
+ { source: 'inbox', urls: inboxLayer }
+ ],
+ {
+ operation: 'read',
+ blockedRelays,
+ maxRelays: PROFILE_REPORTS_MAX_RELAYS,
+ applySocialKindBlockedFilter: false,
+ allowThirdPartyLocalRelays: options.includeAuthorLocalRelays ?? false
+ }
+ )
+}
diff --git a/src/lib/profile-wall-comments.test.ts b/src/lib/profile-wall-comments.test.ts
new file mode 100644
index 00000000..f84635d3
--- /dev/null
+++ b/src/lib/profile-wall-comments.test.ts
@@ -0,0 +1,27 @@
+import { ExtendedKind } from '@/constants'
+import { describe, expect, it } from 'vitest'
+import { isDirectProfileWallComment } from './profile-wall-comments'
+import type { Event } from 'nostr-tools'
+
+const PROFILE_ID = 'a'.repeat(64)
+const PROFILE_PK = 'b'.repeat(64)
+
+describe('isDirectProfileWallComment', () => {
+ it('accepts comment with parent e on profile', () => {
+ const event = {
+ kind: ExtendedKind.COMMENT,
+ id: 'c'.repeat(64),
+ tags: [['e', PROFILE_ID, '', 'reply']]
+ } as Event
+ expect(isDirectProfileWallComment(event, PROFILE_ID, PROFILE_PK)).toBe(true)
+ })
+
+ it('rejects nested reply to another comment', () => {
+ const event = {
+ kind: ExtendedKind.COMMENT,
+ id: 'c'.repeat(64),
+ tags: [['e', 'd'.repeat(64), '', 'reply']]
+ } as Event
+ expect(isDirectProfileWallComment(event, PROFILE_ID, PROFILE_PK)).toBe(false)
+ })
+})
diff --git a/src/lib/profile-wall-comments.ts b/src/lib/profile-wall-comments.ts
new file mode 100644
index 00000000..d82fd02d
--- /dev/null
+++ b/src/lib/profile-wall-comments.ts
@@ -0,0 +1,25 @@
+import { ExtendedKind } from '@/constants'
+import { getParentATag, getParentEventHexId, getReplaceableCoordinate, normalizeReplaceableCoordinateString } from '@/lib/event'
+import { Event, kinds } from 'nostr-tools'
+
+/** Kind 1111 comment whose immediate parent is the profile kind-0 event (by id or coordinate). */
+export function isDirectProfileWallComment(
+ event: Event,
+ profileEventId: string,
+ profilePubkey: string
+): boolean {
+ if (event.kind !== ExtendedKind.COMMENT) return false
+ const profileId = profileEventId.trim().toLowerCase()
+ if (!/^[0-9a-f]{64}$/.test(profileId)) return false
+
+ const parentHex = getParentEventHexId(event)?.trim().toLowerCase()
+ if (parentHex === profileId) return true
+
+ const profileCoord = normalizeReplaceableCoordinateString(
+ getReplaceableCoordinate(kinds.Metadata, profilePubkey, '')
+ )
+ const parentA = getParentATag(event)?.[1]
+ if (parentA && normalizeReplaceableCoordinateString(parentA) === profileCoord) return true
+
+ return false
+}