Browse Source

add reports tabs and wall tab

imwald
Silberengel 4 weeks ago
parent
commit
11cfea3650
  1. 94
      src/components/Profile/ProfileReportsFeed.tsx
  2. 7
      src/components/Profile/ProfileTimeline.tsx
  3. 117
      src/components/Profile/ProfileWallFeed.tsx
  4. 33
      src/components/Profile/index.tsx
  5. 4
      src/constants.ts
  6. 323
      src/hooks/useProfileReportsEvents.tsx
  7. 45
      src/hooks/useProfileReportsRelayBuilder.tsx
  8. 60
      src/hooks/useProfileTimeline.tsx
  9. 179
      src/hooks/useProfileWall.tsx
  10. 11
      src/i18n/locales/de.ts
  11. 11
      src/i18n/locales/en.ts
  12. 33
      src/lib/nip56-reports.ts
  13. 33
      src/lib/nip58-profile-badges.test.ts
  14. 82
      src/lib/nip58-profile-badges.ts
  15. 24
      src/lib/profile-reports-relays.test.ts
  16. 53
      src/lib/profile-reports-relays.ts
  17. 27
      src/lib/profile-wall-comments.test.ts
  18. 25
      src/lib/profile-wall-comments.ts

94
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 (
<div className="mt-4 space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
return (
<div className="mt-4 space-y-8">
{isRefreshing && (
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{t('Refreshing reports...')}
</div>
)}
<section className="space-y-2" aria-labelledby="profile-reports-received-heading">
<h2
id="profile-reports-received-heading"
className="px-4 text-sm font-semibold text-foreground"
>
{t('Reports received')}
</h2>
{received.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground">{t('No reports received')}</p>
) : (
<div className="space-y-2">
{received.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
)}
</section>
<section className="space-y-2" aria-labelledby="profile-reports-made-heading">
<h2 id="profile-reports-made-heading" className="px-4 text-sm font-semibold text-foreground">
{t('Reports made')}
</h2>
{made.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground">{t('No reports made')}</p>
) : (
<div className="space-y-2">
{made.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
)}
</section>
</div>
)
})
ProfileReportsFeed.displayName = 'ProfileReportsFeed'
export default ProfileReportsFeed

7
src/components/Profile/ProfileTimeline.tsx

@ -4,7 +4,7 @@ import { RefreshCw } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef } from 'react' 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 INITIAL_SHOW_COUNT = 25
const LOAD_MORE_COUNT = 25 const LOAD_MORE_COUNT = 25
@ -18,6 +18,7 @@ interface ProfileTimelineProps {
kinds: number[] kinds: number[]
cacheKey: string cacheKey: string
filterPredicate?: (event: Event) => boolean filterPredicate?: (event: Event) => boolean
relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder
getKindLabel: (kindValue: string) => string getKindLabel: (kindValue: string) => string
refreshLabel: string refreshLabel: string
emptyLabel: string emptyLabel: string
@ -38,6 +39,7 @@ const ProfileTimeline = forwardRef<
kinds: timelineKinds, kinds: timelineKinds,
cacheKey, cacheKey,
filterPredicate, filterPredicate,
relayUrlsBuilder,
getKindLabel, getKindLabel,
refreshLabel, refreshLabel,
emptyLabel, emptyLabel,
@ -54,7 +56,8 @@ const ProfileTimeline = forwardRef<
cacheKey, cacheKey,
kinds: timelineKinds, kinds: timelineKinds,
limit: 200, limit: 200,
filterPredicate filterPredicate,
relayUrlsBuilder
}) })
useEffect(() => { useEffect(() => {

117
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 (
<div className="mt-4 space-y-6 px-4">
<div className="flex gap-3">
<Skeleton className="h-24 w-24 rounded-full md:h-48 md:w-48" />
<Skeleton className="h-24 w-24 rounded-full md:h-48 md:w-48" />
</div>
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
return (
<div className="mt-4 space-y-8">
{isRefreshing && (
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{t('Refreshing wall...')}
</div>
)}
{badges.length > 0 && (
<section className="px-4" aria-label={t('Badges')}>
<div className="flex flex-wrap gap-3 justify-center sm:justify-start">
{badges.map((badge) => (
<div
key={`${badge.definitionCoordinate}:${badge.awardEventId}`}
className="flex flex-col items-center gap-1"
title={badge.description ?? badge.name}
>
{badge.imageUrl ? (
<img
src={badge.imageUrl}
alt={badge.name}
className="h-24 w-24 rounded-lg object-cover md:h-48 md:w-48"
loading="lazy"
/>
) : (
<div
className="flex h-24 w-24 items-center justify-center rounded-lg border border-border bg-muted px-2 text-center text-xs font-medium md:h-48 md:w-48 md:text-sm"
aria-hidden
>
{badge.name}
</div>
)}
<span className="max-w-[6rem] truncate text-center text-xs text-muted-foreground md:max-w-[12rem]">
{badge.name}
</span>
</div>
))}
</div>
</section>
)}
<section className="space-y-2" aria-labelledby="profile-wall-comments-heading">
<h2 id="profile-wall-comments-heading" className="px-4 text-sm font-semibold text-foreground">
{t('Wall')}
</h2>
{!profileEventId ? (
<p className="px-4 py-4 text-sm text-muted-foreground">{t('Profile metadata not loaded yet')}</p>
) : comments.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground">{t('No wall comments yet')}</p>
) : (
<div className="space-y-2">
{comments.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
)}
</section>
</div>
)
}
)
ProfileWallFeed.displayName = 'ProfileWallFeed'
export default ProfileWallFeed

33
src/components/Profile/index.tsx

@ -66,6 +66,8 @@ import ProfileFeedWithPins from './ProfileFeedWithPins'
import ProfileLikedFeed from './ProfileLikedFeed' import ProfileLikedFeed from './ProfileLikedFeed'
import ProfileMediaFeed from './ProfileMediaFeed' import ProfileMediaFeed from './ProfileMediaFeed'
import ProfilePublicationsFeed from './ProfilePublicationsFeed' import ProfilePublicationsFeed from './ProfilePublicationsFeed'
import ProfileReportsFeed from './ProfileReportsFeed'
import ProfileWallFeed from './ProfileWallFeed'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import SmartFollowings from './SmartFollowings' import SmartFollowings from './SmartFollowings'
@ -240,8 +242,12 @@ export default function Profile({
const postsFeedRef = useRef<{ refresh: () => void }>(null) const postsFeedRef = useRef<{ refresh: () => void }>(null)
const mediaFeedRef = useRef<TNoteListRef>(null) const mediaFeedRef = useRef<TNoteListRef>(null)
const publicationsFeedRef = useRef<{ refresh: () => void }>(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 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. */ /** Bumped after profile-view relay sync so payment + kind-0 JSON re-query storage and relays. */
const [authorReplaceablesSyncGen, setAuthorReplaceablesSyncGen] = useState(0) const [authorReplaceablesSyncGen, setAuthorReplaceablesSyncGen] = useState(0)
const profilePubkeyRef = useRef<string | null>(null) const profilePubkeyRef = useRef<string | null>(null)
@ -474,6 +480,10 @@ export default function Profile({
mediaFeedRef.current?.refresh() mediaFeedRef.current?.refresh()
} else if (profileFeedTab === 'publications') { } else if (profileFeedTab === 'publications') {
publicationsFeedRef.current?.refresh() publicationsFeedRef.current?.refresh()
} else if (profileFeedTab === 'reports') {
reportsFeedRef.current?.refresh()
} else if (profileFeedTab === 'wall') {
wallFeedRef.current?.refresh()
} else if (profileFeedTab === 'liked') { } else if (profileFeedTab === 'liked') {
likedFeedRef.current?.refresh() likedFeedRef.current?.refresh()
} }
@ -807,7 +817,14 @@ export default function Profile({
<Tabs <Tabs
value={profileFeedTab} value={profileFeedTab}
onValueChange={(v) => { onValueChange={(v) => {
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) setProfileFeedTab(v)
} }
}} }}
@ -826,6 +843,12 @@ export default function Profile({
> >
{t('Articles and Publications')} {t('Articles and Publications')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="reports" className="shrink-0">
{t('Reports')}
</TabsTrigger>
<TabsTrigger value="wall" className="shrink-0">
{t('Wall')}
</TabsTrigger>
{isSelf && ( {isSelf && (
<TabsTrigger value="liked" className="shrink-0"> <TabsTrigger value="liked" className="shrink-0">
{t('Liked')} {t('Liked')}
@ -841,6 +864,12 @@ export default function Profile({
<TabsContent value="publications" className="min-w-0 focus-visible:outline-none"> <TabsContent value="publications" className="min-w-0 focus-visible:outline-none">
<ProfilePublicationsFeed ref={publicationsFeedRef} pubkey={pubkey} /> <ProfilePublicationsFeed ref={publicationsFeedRef} pubkey={pubkey} />
</TabsContent> </TabsContent>
<TabsContent value="reports" className="min-w-0 focus-visible:outline-none">
<ProfileReportsFeed ref={reportsFeedRef} pubkey={pubkey} />
</TabsContent>
<TabsContent value="wall" className="min-w-0 focus-visible:outline-none">
<ProfileWallFeed ref={wallFeedRef} pubkey={pubkey} profileEventId={profileEvent?.id} />
</TabsContent>
{isSelf && ( {isSelf && (
<TabsContent value="liked" className="min-w-0 focus-visible:outline-none"> <TabsContent value="liked" className="min-w-0 focus-visible:outline-none">
<ProfileLikedFeed ref={likedFeedRef} pubkey={pubkey} /> <ProfileLikedFeed ref={likedFeedRef} pubkey={pubkey} />

4
src/constants.ts

@ -577,8 +577,10 @@ export const ExtendedKind = {
CALENDAR_EVENT_RSVP: 31925, CALENDAR_EVENT_RSVP: 31925,
/** NIP-A7 Spells: portable relay query filters (kind 777) */ /** NIP-A7 Spells: portable relay query filters (kind 777) */
SPELL: 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, 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) */ /** NIP-58 Badges: badge definition (addressable) */
BADGE_DEFINITION: 30009, BADGE_DEFINITION: 30009,
/** Web page bookmark (URL in i/I or r tags); used in RSS+Web relay discovery */ /** Web page bookmark (URL in i/I or r tags); used in RSS+Web relay discovery */

323
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<string, CacheEntry>()
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<string, Event>()
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<Event[]>(receivedCached?.events ?? [])
const [made, setMade] = useState<Event[]>(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<string, Event>()
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 }
}

45
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<string[]>([])
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<ProfileTimelineRelayUrlsBuilder>(
(_favoriteRelays, blocked, authorRelayList, includeAuthorLocalRelays) =>
buildProfileReportsRelayUrls(authorRelayList, blocked, {
includeAuthorLocalRelays,
cacheRelayUrls: isSelf ? cacheRelayUrls : []
}),
[cacheRelayUrls, isSelf]
)
}

60
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 { CALENDAR_EVENT_KINDS, ExtendedKind, isDocumentRelayKind, isSocialKindBlockedKind } from '@/constants'
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import type { ProfileReportsRelayList } from '@/lib/profile-reports-relays'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -21,12 +22,21 @@ type ProfileTimelineMemoryEntry = {
const memoryTimelineByKey = new Map<string, ProfileTimelineMemoryEntry>() const memoryTimelineByKey = new Map<string, ProfileTimelineMemoryEntry>()
const CACHE_DURATION = 5 * 60 * 1000 const CACHE_DURATION = 5 * 60 * 1000
export type ProfileTimelineRelayUrlsBuilder = (
favoriteRelays: string[],
blockedRelays: string[],
authorRelayList: ProfileReportsRelayList,
includeAuthorLocalRelays: boolean
) => string[]
type UseProfileTimelineOptions = { type UseProfileTimelineOptions = {
pubkey: string pubkey: string
cacheKey: string cacheKey: string
kinds: number[] kinds: number[]
limit?: number limit?: number
filterPredicate?: (event: Event) => boolean filterPredicate?: (event: Event) => boolean
/** When set, replaces {@link buildProfilePageReadRelayUrls} (e.g. profile Reports tab inboxes only). */
relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder
} }
type UseProfileTimelineResult = { type UseProfileTimelineResult = {
@ -127,7 +137,8 @@ export function useProfileTimeline({
cacheKey, cacheKey,
kinds, kinds,
limit = 200, limit = 200,
filterPredicate filterPredicate,
relayUrlsBuilder
}: UseProfileTimelineOptions): UseProfileTimelineResult { }: UseProfileTimelineOptions): UseProfileTimelineResult {
const nostr = useNostrOptional() const nostr = useNostrOptional()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -151,9 +162,38 @@ export function useProfileTimeline({
const filterPredicateRef = useRef(filterPredicate) const filterPredicateRef = useRef(filterPredicate)
filterPredicateRef.current = filterPredicate filterPredicateRef.current = filterPredicate
const relayUrlsBuilderRef = useRef(relayUrlsBuilder)
relayUrlsBuilderRef.current = relayUrlsBuilder
const limitRef = useRef(limit) const limitRef = useRef(limit)
limitRef.current = 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 cachedEntry = useMemo(() => memoryTimelineByKey.get(cacheKey), [cacheKey])
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? []) const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? [])
const [isLoading, setIsLoading] = useState(!cachedEntry) const [isLoading, setIsLoading] = useState(!cachedEntry)
@ -307,11 +347,10 @@ export function useProfileTimeline({
const authorRelayPromise = client.fetchRelayList(pubkey).catch(() => emptyAuthor) const authorRelayPromise = client.fetchRelayList(pubkey).catch(() => emptyAuthor)
const provisionalFeedUrls = buildProfilePageReadRelayUrls( const provisionalFeedUrls = resolveFeedUrls(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
emptyAuthor, emptyAuthor,
socialKinds,
includeAuthorLocalRelays, includeAuthorLocalRelays,
kinds, kinds,
useGlobalRelayBootstrap useGlobalRelayBootstrap
@ -403,11 +442,10 @@ export function useProfileTimeline({
void (async () => { void (async () => {
const authorRl = await authorRelayPromise const authorRl = await authorRelayPromise
if (cancelled) return if (cancelled) return
const fullFeedUrls = buildProfilePageReadRelayUrls( const fullFeedUrls = resolveFeedUrls(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
authorRl, authorRl,
socialKinds,
includeAuthorLocalRelays, includeAuthorLocalRelays,
kinds, kinds,
useGlobalRelayBootstrap useGlobalRelayBootstrap
@ -443,7 +481,17 @@ export function useProfileTimeline({
subscriptionRef.current() subscriptionRef.current()
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(() => { const refresh = useCallback(() => {
subscriptionRef.current() subscriptionRef.current()

179
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<string, { badges: ResolvedProfileBadge[]; comments: Event[]; lastUpdated: number }>()
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<ResolvedProfileBadge[]>(cached?.badges ?? [])
const [comments, setComments] = useState<Event[]>(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<string, Event | undefined>()
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<string, Event>()
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 }
}

11
src/i18n/locales/de.ts

@ -797,6 +797,17 @@ export default {
"Search articles...": "Artikel suchen…", "Search articles...": "Artikel suchen…",
"Refreshing articles...": "Artikel werden aktualisiert…", "Refreshing articles...": "Artikel werden aktualisiert…",
"No articles or publications found": "Keine Artikel oder Veröffentlichungen gefunden", "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", "No articles or publications match your search": "Keine Artikel oder Veröffentlichungen entsprechen der Suche",
"articles and publications": "Artikel und Veröffentlichungen", "articles and publications": "Artikel und Veröffentlichungen",
Interests: "Interessen", Interests: "Interessen",

11
src/i18n/locales/en.ts

@ -832,6 +832,17 @@ export default {
"Refreshing articles...": "Refreshing articles...", "Refreshing articles...": "Refreshing articles...",
"No articles or publications found": "No articles or publications found", "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 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", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", Favorites: "Favorites",

33
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()
}
}

33
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'
})
})
})

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

24
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)
})
})

53
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
}
)
}

27
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)
})
})

25
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
}
Loading…
Cancel
Save