18 changed files with 1150 additions and 11 deletions
@ -0,0 +1,94 @@
@@ -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 |
||||
@ -0,0 +1,117 @@
@@ -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 |
||||
@ -0,0 +1,323 @@
@@ -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 } |
||||
} |
||||
@ -0,0 +1,45 @@
@@ -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] |
||||
) |
||||
} |
||||
@ -0,0 +1,179 @@
@@ -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 } |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -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() |
||||
} |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -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' |
||||
}) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,82 @@
@@ -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 |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -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) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,53 @@
@@ -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 |
||||
} |
||||
) |
||||
} |
||||
@ -0,0 +1,27 @@
@@ -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) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,25 @@
@@ -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…
Reference in new issue