import NoteCard from '@/components/NoteCard' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { Event, kinds } from 'nostr-tools' import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef, useCallback } from 'react' import client from '@/services/client.service' import { FAST_READ_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import { useZap } from '@/providers/ZapProvider' import logger from '@/lib/logger' const INITIAL_SHOW_COUNT = 25 const LOAD_MORE_COUNT = 25 const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes type InteractionsCacheEntry = { events: Event[] lastUpdated: number } const interactionsCache = new Map() interface ProfileInteractionsProps { accountPubkey: string profilePubkey: string topSpace?: number searchQuery?: string onEventsChange?: (events: Event[]) => void } const ProfileInteractions = forwardRef< { refresh: () => void; getEvents?: () => Event[] }, ProfileInteractionsProps >( ( { accountPubkey, profilePubkey, topSpace, searchQuery = '', onEventsChange }, ref ) => { const { zapReplyThreshold } = useZap() const [isRefreshing, setIsRefreshing] = useState(false) const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) const [events, setEvents] = useState([]) const [isLoading, setIsLoading] = useState(true) const [refreshToken, setRefreshToken] = useState(0) const bottomRef = useRef(null) // Create cache key based on account and profile pubkeys const cacheKey = useMemo(() => `${accountPubkey}-${profilePubkey}-${zapReplyThreshold}`, [accountPubkey, profilePubkey, zapReplyThreshold]) const fetchInteractions = useCallback(async () => { // Check cache first const cachedEntry = interactionsCache.get(cacheKey) const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity const isCacheFresh = cacheAge < CACHE_DURATION // If cache is fresh, show it immediately if (isCacheFresh && cachedEntry) { setEvents(cachedEntry.events) setIsLoading(false) // Still fetch in background to get updates } else { setIsLoading(!cachedEntry) } try { const relayUrls = FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) // Fetch events where accountPubkey interacted with profilePubkey // 1. Replies: accountPubkey replied to profilePubkey's notes // 2. Zaps: accountPubkey zapped profilePubkey // 3. Mentions: accountPubkey mentioned profilePubkey // 4. Replies to accountPubkey: profilePubkey replied to accountPubkey's notes const filters: any[] = [] // Get profilePubkey's notes to find replies to them const profileNotes = await client.fetchEvents(relayUrls, [{ authors: [profilePubkey], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION], limit: 100 }]) const profileNoteIds = profileNotes.map(e => e.id) // Replies from accountPubkey to profilePubkey's notes if (profileNoteIds.length > 0) { filters.push({ authors: [accountPubkey], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], '#e': profileNoteIds, limit: 100 }) } // Zaps from accountPubkey to profilePubkey filters.push({ authors: [accountPubkey], kinds: [kinds.Zap], '#p': [profilePubkey], limit: 100 }) // Mentions: accountPubkey mentioned profilePubkey filters.push({ authors: [accountPubkey], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE], '#p': [profilePubkey], limit: 100 }) // Get accountPubkey's notes to find replies from profilePubkey const accountNotes = await client.fetchEvents(relayUrls, [{ authors: [accountPubkey], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION], limit: 100 }]) const accountNoteIds = accountNotes.map(e => e.id) // Replies from profilePubkey to accountPubkey's notes if (accountNoteIds.length > 0) { filters.push({ authors: [profilePubkey], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], '#e': accountNoteIds, limit: 100 }) } // Zaps from profilePubkey to accountPubkey filters.push({ authors: [profilePubkey], kinds: [kinds.Zap], '#p': [accountPubkey], limit: 100 }) // Mentions: profilePubkey mentioned accountPubkey filters.push({ authors: [profilePubkey], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE], '#p': [accountPubkey], limit: 100 }) const allEvents = await client.fetchEvents(relayUrls, filters) // Deduplicate and filter const seenIds = new Set() const uniqueEvents = allEvents.filter(event => { if (seenIds.has(event.id)) return false seenIds.add(event.id) // Filter zap receipts below threshold if (event.kind === ExtendedKind.ZAP_RECEIPT) { const zapInfo = getZapInfoFromEvent(event) if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) { return false } } return true }) // Sort by created_at descending uniqueEvents.sort((a, b) => b.created_at - a.created_at) // Update cache interactionsCache.set(cacheKey, { events: uniqueEvents, lastUpdated: Date.now() }) setEvents(uniqueEvents) } catch (error) { logger.error('Failed to fetch interactions', error) setEvents([]) } finally { setIsLoading(false) setIsRefreshing(false) } }, [accountPubkey, profilePubkey, zapReplyThreshold, cacheKey]) useEffect(() => { if (!accountPubkey || !profilePubkey) return fetchInteractions() }, [accountPubkey, profilePubkey, refreshToken, fetchInteractions]) useEffect(() => { onEventsChange?.(events) }, [events, onEventsChange]) useImperativeHandle( ref, () => ({ refresh: () => { setIsRefreshing(true) // Clear cache on refresh interactionsCache.delete(cacheKey) setRefreshToken((prev) => prev + 1) }, getEvents: () => events }), [events] ) const filteredEvents = useMemo(() => { if (!searchQuery.trim()) { return events } const query = searchQuery.toLowerCase().trim() return events.filter((event) => { const contentLower = event.content.toLowerCase() if (contentLower.includes(query)) return true return event.tags.some((tag) => { if (tag.length <= 1) return false const tagValue = tag[1] return tagValue && tagValue.toLowerCase().includes(query) }) }) }, [events, searchQuery]) // Reset showCount when filters change useEffect(() => { setShowCount(INITIAL_SHOW_COUNT) }, [searchQuery]) // Pagination: slice to showCount for display const displayedEvents = useMemo(() => { return filteredEvents.slice(0, showCount) }, [filteredEvents, showCount]) // IntersectionObserver for infinite scroll useEffect(() => { if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) { setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length)) } }, { threshold: 0.1 } ) observer.observe(bottomRef.current) return () => { observer.disconnect() } }, [displayedEvents.length, filteredEvents.length]) if (!accountPubkey || !profilePubkey) { return (
No interactions to show
) } if (isLoading && events.length === 0) { return (
{Array.from({ length: 3 }).map((_, i) => ( ))}
) } if (!filteredEvents.length && !isLoading) { return (
{searchQuery.trim() ? 'No interactions match your search' : 'No interactions found'}
) } return (
{isRefreshing && (
🔄 Refreshing interactions...
)} {searchQuery.trim() && (
Showing {displayedEvents.length} of {filteredEvents.length} interactions
)}
{displayedEvents.map((event) => ( ))}
{displayedEvents.length < filteredEvents.length && (
Loading more...
)}
) } ) ProfileInteractions.displayName = 'ProfileInteractions' export default ProfileInteractions