18 changed files with 1150 additions and 11 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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