diff --git a/src/components/Profile/ProfileReportsFeed.tsx b/src/components/Profile/ProfileReportsFeed.tsx new file mode 100644 index 00000000..4852f4f7 --- /dev/null +++ b/src/components/Profile/ProfileReportsFeed.tsx @@ -0,0 +1,94 @@ +import NoteCard from '@/components/NoteCard' +import { Skeleton } from '@/components/ui/skeleton' +import { useProfileReportsEvents } from '@/hooks/useProfileReportsEvents' +import { useProfileReportsRelayBuilder } from '@/hooks/useProfileReportsRelayBuilder' +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RefreshCw } from 'lucide-react' + +const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { + const { t } = useTranslation() + const relayUrlsBuilder = useProfileReportsRelayBuilder(pubkey) + const { received, made, isLoading, refresh } = useProfileReportsEvents({ + pubkey, + relayUrlsBuilder + }) + const [isRefreshing, setIsRefreshing] = useState(false) + + useEffect(() => { + if (!isLoading) setIsRefreshing(false) + }, [isLoading]) + + useImperativeHandle( + ref, + () => ({ + refresh: () => { + setIsRefreshing(true) + refresh() + } + }), + [refresh] + ) + + if (isLoading && received.length === 0 && made.length === 0) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) + } + + return ( +
+ {isRefreshing && ( +
+ + {t('Refreshing reports...')} +
+ )} + +
+

+ {t('Reports received')} +

+ {received.length === 0 ? ( +

{t('No reports received')}

+ ) : ( +
+ {received.map((event) => ( + + ))} +
+ )} +
+ +
+

+ {t('Reports made')} +

+ {made.length === 0 ? ( +

{t('No reports made')}

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

+ {t('Wall')} +

+ {!profileEventId ? ( +

{t('Profile metadata not loaded yet')}

+ ) : comments.length === 0 ? ( +

{t('No wall comments yet')}

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