diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index c23e17c..5e79512 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -166,6 +166,8 @@ const NoteList = forwardRef( onEvents: (events, eosed) => { if (events.length > 0) { setEvents(events) + // Stop loading as soon as we have events, don't wait for all relays + setLoading(false) } if (areAlgoRelays) { setHasMore(false) diff --git a/src/components/QrCode/index.tsx b/src/components/QrCode/index.tsx index 2f2e33e..d1a4f7c 100644 --- a/src/components/QrCode/index.tsx +++ b/src/components/QrCode/index.tsx @@ -1,6 +1,5 @@ import QRCodeStyling from 'qr-code-styling' import { useEffect, useRef } from 'react' -import iconSvg from '../../../public/favicon.svg' export default function QrCode({ value, size = 180 }: { value: string; size?: number }) { const ref = useRef(null) @@ -13,7 +12,7 @@ export default function QrCode({ value, size = 180 }: { value: string; size?: nu qrOptions: { errorCorrectionLevel: 'M' }, - image: iconSvg, + image: '/favicon.svg', width: size * pixelRatio, height: size * pixelRatio, data: value, diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx index a40c05c..7c46a69 100644 --- a/src/pages/primary/DiscussionsPage/index.tsx +++ b/src/pages/primary/DiscussionsPage/index.tsx @@ -3,7 +3,7 @@ import { Card, CardContent } from '@/components/ui/card' import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' -import { forwardRef, useEffect, useState } from 'react' +import { forwardRef, useEffect, useState, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { MessageSquarePlus, Book, BookOpen } from 'lucide-react' @@ -42,160 +42,67 @@ const DiscussionsPage = forwardRef((_, ref) => { const [filterBy, setFilterBy] = useState<'author' | 'subject' | 'all'>('all') // Use DEFAULT_FAVORITE_RELAYS for logged-out users, or user's favorite relays for logged-in users - const availableRelays = pubkey && favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS + const availableRelays = useMemo(() => + pubkey && favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS, + [pubkey, favoriteRelays] + ) - // Available topic IDs for matching - const availableTopicIds = DISCUSSION_TOPICS.map(topic => topic.id) + // Memoize relay URLs with deduplication + const relayUrls = useMemo(() => { + if (selectedRelay) return [selectedRelay] + // Deduplicate and combine relays + return Array.from(new Set([...availableRelays, ...FAST_READ_RELAY_URLS])) + }, [selectedRelay, availableRelays]) - // Custom function to fetch vote stats from selected relays only - const fetchVoteStatsFromRelays = async (thread: NostrEvent, relayUrls: string[]) => { - try { - const reactions = await client.fetchEvents(relayUrls, [ - { - '#e': [thread.id], - kinds: [kinds.Reaction], - limit: 500 - } - ]) - - // Filter for up/down vote reactions only - const upvotes = reactions.filter(r => r.content === '⬆️') - const downvotes = reactions.filter(r => r.content === '⬇️') - - return { - upvotes: upvotes.length, - downvotes: downvotes.length, - score: upvotes.length - downvotes.length, - controversy: Math.min(upvotes.length, downvotes.length) - } - } catch (error) { - console.error('Error fetching vote stats for thread', thread.id, error) - return { upvotes: 0, downvotes: 0, score: 0, controversy: 0 } - } - } + // Available topic IDs for matching + const availableTopicIds = useMemo(() => + DISCUSSION_TOPICS.map(topic => topic.id), + [] + ) - // Helper function to get vote score for a thread - const getThreadVoteScore = (thread: NostrEvent) => { - // Use custom vote stats if available (from selected relays), otherwise fall back to noteStatsService - if (customVoteStats[thread.id]) { - const stats = customVoteStats[thread.id] - console.log(`Thread ${thread.id}: upvotes=${stats.upvotes}, downvotes=${stats.downvotes}, score=${stats.score} (custom)`) - return stats.score - } - - const stats = noteStatsService.getNoteStats(thread.id) - if (!stats?.likes) { - console.log(`No stats for thread ${thread.id}`) - return 0 + // Memoize helper functions to prevent recreating on every render + const getThreadVoteScore = useCallback((thread: NostrEvent) => { + const threadId = thread.id + if (customVoteStats[threadId]) { + return customVoteStats[threadId].score } - + const stats = noteStatsService.getNoteStats(threadId) + if (!stats?.likes) return 0 const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆️') const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇️') - const score = upvoteReactions.length - downvoteReactions.length - - console.log(`Thread ${thread.id}: upvotes=${upvoteReactions.length}, downvotes=${downvoteReactions.length}, score=${score} (fallback)`) - return score - } + return upvoteReactions.length - downvoteReactions.length + }, [customVoteStats]) - // Helper function to get controversy score (high upvotes AND downvotes) - const getThreadControversyScore = (thread: NostrEvent) => { - // Use custom vote stats if available (from selected relays), otherwise fall back to noteStatsService - if (customVoteStats[thread.id]) { - const stats = customVoteStats[thread.id] - console.log(`Thread ${thread.id}: upvotes=${stats.upvotes}, downvotes=${stats.downvotes}, controversy=${stats.controversy} (custom)`) - return stats.controversy + const getThreadControversyScore = useCallback((thread: NostrEvent) => { + const threadId = thread.id + if (customVoteStats[threadId]) { + return customVoteStats[threadId].controversy } - - const stats = noteStatsService.getNoteStats(thread.id) - if (!stats?.likes) { - console.log(`No stats for thread ${thread.id}`) - return 0 - } - + const stats = noteStatsService.getNoteStats(threadId) + if (!stats?.likes) return 0 const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆️') const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇️') - - // Controversy = minimum of upvotes and downvotes (both need to be high) - const controversy = Math.min(upvoteReactions.length, downvoteReactions.length) - console.log(`Thread ${thread.id}: upvotes=${upvoteReactions.length}, downvotes=${downvoteReactions.length}, controversy=${controversy} (fallback)`) - return controversy - } + const balance = Math.min(upvoteReactions.length, downvoteReactions.length) + const magnitude = upvoteReactions.length + downvoteReactions.length + return balance * magnitude + }, [customVoteStats]) - // Helper function to get total zap amount for a thread - const getThreadZapAmount = (thread: NostrEvent) => { + const getThreadZapAmount = useCallback((thread: NostrEvent) => { const stats = noteStatsService.getNoteStats(thread.id) if (!stats?.zaps) { return 0 } - const totalAmount = stats.zaps.reduce((sum, zap) => sum + zap.amount, 0) console.log(`Thread ${thread.id}: ${stats.zaps.length} zaps, total amount: ${totalAmount}`) return totalAmount - } - - useEffect(() => { - setCustomVoteStats({}) // Clear custom stats when relay changes - fetchAllThreads() - }, [selectedRelay]) - - useEffect(() => { - // Only wait for stats for vote-based sorting - if ((selectedSort === 'top' || selectedSort === 'controversial') && !statsLoaded) { - console.log('Waiting for stats to load before sorting...') - return - } - console.log('Running filterThreadsByTopic with selectedSort:', selectedSort, 'statsLoaded:', statsLoaded, 'viewMode:', viewMode, 'selectedTopic:', selectedTopic) - filterThreadsByTopic() - }, [allThreads, selectedTopic, selectedSubtopic, selectedSort, statsLoaded, viewMode, searchQuery, filterBy]) - - // Fetch stats when sort changes to top/controversial - useEffect(() => { - if ((selectedSort === 'top' || selectedSort === 'controversial') && allThreads.length > 0) { - setStatsLoaded(false) - console.log('Fetching vote stats for', allThreads.length, 'threads from relays:', selectedRelay || availableRelays) - - // Use the same relay selection as thread fetching - const relayUrls = selectedRelay ? [selectedRelay] : availableRelays - - // Fetch custom vote stats from selected relays only - const statsPromises = allThreads.map(async (thread) => { - try { - const stats = await fetchVoteStatsFromRelays(thread, relayUrls) - return { threadId: thread.id, stats } - } catch (error) { - console.error('Error fetching stats for thread', thread.id, error) - return { threadId: thread.id, stats: { upvotes: 0, downvotes: 0, score: 0, controversy: 0 } } - } - }) - - Promise.allSettled(statsPromises).then((results) => { - const successful = results.filter(r => r.status === 'fulfilled').length - console.log(`Vote stats fetch completed: ${successful}/${results.length} successful`) - - // Store the custom vote stats - const newCustomStats: Record = {} - results.forEach(result => { - if (result.status === 'fulfilled') { - newCustomStats[result.value.threadId] = result.value.stats - } - }) - - setCustomVoteStats(newCustomStats) - setStatsLoaded(true) - }) - } else { - setStatsLoaded(true) // For non-vote-based sorting, stats don't matter - console.log('Set statsLoaded to true for non-vote sorting') - } - }, [selectedSort, allThreads, selectedRelay, availableRelays]) + }, []) - const fetchAllThreads = async () => { + // Memoize fetchAllThreads to prevent recreating on every render + const fetchAllThreads = useCallback(async () => { setLoading(true) + setCustomVoteStats({}) // Clear custom stats when fetching try { - // Filter by relay if selected, otherwise use all available relays plus fast read relays - const relayUrls = selectedRelay ? [selectedRelay] : Array.from(new Set([...availableRelays, ...FAST_READ_RELAY_URLS])) - - // Fetch all kind 11 events (limit 100, newest first) with relay source tracking + // Fetch all kind 11 events (limit 100, newest first) console.log('Fetching kind 11 events from relays:', relayUrls) // Fetch recent kind 11 events (last 30 days) const thirtyDaysAgo = Math.floor((Date.now() - (30 * 24 * 60 * 60 * 1000)) / 1000) @@ -207,7 +114,7 @@ const DiscussionsPage = forwardRef((_, ref) => { limit: 100 } ]) - console.log('Fetched kind 11 events:', events.length, events.map(e => ({ id: e.id, title: e.tags.find(t => t[0] === 'title')?.[1], pubkey: e.pubkey }))) + console.log('Fetched kind 11 events:', events.length) // Debug: Show date range of fetched events if (events.length > 0) { @@ -215,29 +122,10 @@ const DiscussionsPage = forwardRef((_, ref) => { const newest = new Date(Math.max(...dates.map(d => d.getTime()))) const oldest = new Date(Math.min(...dates.map(d => d.getTime()))) console.log(`Date range: ${oldest.toISOString()} to ${newest.toISOString()}`) - console.log(`Current time: ${new Date().toISOString()}`) console.log(`Newest thread is ${Math.floor((Date.now() - newest.getTime()) / (1000 * 60 * 60 * 24))} days old`) - } else { - console.log('No recent events found, fetching all events...') - // If no recent events, fetch all events without time filter - const allEvents = await client.fetchEvents(relayUrls, [ - { - kinds: [11], // Thread events - limit: 100 - } - ]) - console.log('Fetched all kind 11 events:', allEvents.length) - if (allEvents.length > 0) { - const dates = allEvents.map(e => new Date(e.created_at * 1000)) - const newest = new Date(Math.max(...dates.map(d => d.getTime()))) - const oldest = new Date(Math.min(...dates.map(d => d.getTime()))) - console.log(`All events date range: ${oldest.toISOString()} to ${newest.toISOString()}`) - console.log(`Newest thread is ${Math.floor((Date.now() - newest.getTime()) / (1000 * 60 * 60 * 24))} days old`) - } - return // Use the events we already fetched } - // Filter and sort threads, adding relay source information + // Filter and sort threads const validThreads = events .filter(event => { // Ensure it has a title tag @@ -246,28 +134,83 @@ const DiscussionsPage = forwardRef((_, ref) => { }) .map(event => ({ ...event, - _relaySource: selectedRelay || 'multiple' // Track which relay(s) it was found on + _relaySource: selectedRelay || 'multiple' })) setAllThreads(validThreads) - - // Fetch stats for all threads to enable proper sorting - if (selectedSort === 'top' || selectedSort === 'controversial') { - // Fetch stats for all threads in parallel - const statsPromises = validThreads.map(thread => - noteStatsService.fetchNoteStats(thread, pubkey) - ) - await Promise.allSettled(statsPromises) - } } catch (error) { console.error('Error fetching threads:', error) setAllThreads([]) } finally { setLoading(false) } - } + }, [relayUrls, selectedRelay, selectedSort, pubkey]) + + useEffect(() => { + fetchAllThreads() + }, [fetchAllThreads]) - const filterThreadsByTopic = () => { + useEffect(() => { + // Only wait for stats for vote-based sorting + if ((selectedSort === 'top' || selectedSort === 'controversial') && !statsLoaded) { + console.log('Waiting for stats to load before sorting...') + return + } + console.log('Running filterThreadsByTopic with selectedSort:', selectedSort, 'statsLoaded:', statsLoaded, 'viewMode:', viewMode, 'selectedTopic:', selectedTopic) + filterThreadsByTopic() + }, [allThreads, selectedTopic, selectedSubtopic, selectedSort, statsLoaded, viewMode, searchQuery, filterBy]) + + // Fetch stats when sort changes to top/controversial + useEffect(() => { + if ((selectedSort === 'top' || selectedSort === 'controversial') && allThreads.length > 0) { + setStatsLoaded(false) + console.log('Fetching vote stats for', allThreads.length, 'threads from relays:', selectedRelay || availableRelays) + + // Use the same relay selection as thread fetching + const relayUrls = selectedRelay ? [selectedRelay] : availableRelays + + // Fetch ALL reactions in a single batch request instead of per-thread + const threadIds = allThreads.map(t => t.id) + + client.fetchEvents(relayUrls, [ + { + '#e': threadIds, + kinds: [kinds.Reaction], + limit: 500 + } + ]).then((reactions) => { + // Group reactions by thread + const newCustomStats: Record = {} + + allThreads.forEach(thread => { + const threadReactions = reactions.filter(r => + r.tags.some(tag => tag[0] === 'e' && tag[1] === thread.id) + ) + const upvotes = threadReactions.filter(r => r.content === '⬆️') + const downvotes = threadReactions.filter(r => r.content === '⬇️') + + newCustomStats[thread.id] = { + upvotes: upvotes.length, + downvotes: downvotes.length, + score: upvotes.length - downvotes.length, + controversy: Math.min(upvotes.length, downvotes.length) + } + }) + + setCustomVoteStats(newCustomStats) + setStatsLoaded(true) + console.log(`Vote stats fetch completed for ${allThreads.length} threads`) + }).catch((error) => { + console.error('Error fetching vote stats:', error) + setStatsLoaded(true) + }) + } else { + setStatsLoaded(true) // For non-vote-based sorting, stats don't matter + console.log('Set statsLoaded to true for non-vote sorting') + } + }, [selectedSort, allThreads, selectedRelay, availableRelays]) + + const filterThreadsByTopic = useCallback(() => { const categorizedThreads = allThreads.map(thread => { // Find all 't' tags in the thread const topicTags = thread.tags.filter(tag => tag[0] === 't' && tag[1]) @@ -462,10 +405,23 @@ const DiscussionsPage = forwardRef((_, ref) => { setThreads(threadsForTopic) setGroupedThreads({}) // Clear grouped threads } - } + }, [ + allThreads, + availableTopicIds, + selectedTopic, + selectedSubtopic, + selectedSort, + viewMode, + searchQuery, + filterBy, + customVoteStats, + getThreadVoteScore, + getThreadControversyScore, + getThreadZapAmount + ]) // Helper function to sort threads - const sortThreads = (threadsToSort: NostrEvent[]) => { + const sortThreads = useCallback((threadsToSort: NostrEvent[]) => { const sortedThreads = [...threadsToSort] switch (selectedSort) { @@ -490,7 +446,7 @@ const DiscussionsPage = forwardRef((_, ref) => { default: return sortedThreads.sort((a, b) => b.created_at - a.created_at) } - } + }, [selectedSort, getThreadVoteScore, getThreadControversyScore]) const handleCreateThread = () => { setShowCreateThread(true) diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index 83f4fbf..f452970 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -99,7 +99,6 @@ export function NotificationProvider({ children }: { children: React.ReactNode } let eosed = false const relayList = await client.fetchRelayList(pubkey) const notificationRelays = relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS - console.log('🔔 Notification subscription for', pubkey.substring(0, 8) + '...', 'using relays:', notificationRelays) const subCloser = client.subscribe( notificationRelays, [ @@ -130,16 +129,6 @@ export function NotificationProvider({ children }: { children: React.ReactNode } }, onevent: (evt) => { if (evt.pubkey !== pubkey) { - // Debug: Log public message notifications - if (evt.kind === ExtendedKind.PUBLIC_MESSAGE) { - const hasUserInPTags = evt.tags.some((tag) => tag[0] === 'p' && tag[1] === pubkey) - console.log(`📨 Public message notification received by ${pubkey.substring(0, 8)}... from ${evt.pubkey.substring(0, 8)}...:`, { - hasUserInPTags, - content: evt.content.substring(0, 50), - tags: evt.tags.map(tag => `${tag[0]}:${tag[1]?.substring(0, 8)}...`), - eventId: evt.id.substring(0, 8) + '...' - }) - } setNewNotifications((prev) => { if (!eosed) { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 9659c48..e22eced 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -61,7 +61,7 @@ class ClientService extends EventTarget { ) private trendingNotesCache: NEvent[] | null = null private requestThrottle = new Map() // Track request timestamps per relay - private readonly REQUEST_COOLDOWN = 1000 // 1 second cooldown between requests + private readonly REQUEST_COOLDOWN = 2000 // 2 second cooldown between requests to prevent "too many REQs" private failureCount = new Map() // Track consecutive failures per relay private readonly MAX_FAILURES = 3 // Max failures before exponential backoff private circuitBreaker = new Map() // Track when relays are temporarily disabled @@ -475,6 +475,7 @@ class ClientService extends EventTarget { let eventIdSet = new Set() let events: NEvent[] = [] let eosedCount = 0 + let hasCalledOnEvents = false const subs = await Promise.all( subRequests.map(async ({ urls, filter }) => { @@ -500,7 +501,11 @@ class ClientService extends EventTarget { events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) eventIdSet = new Set(events.map((evt) => evt.id)) - if (eosedCount >= threshold) { + // Call immediately on first events, then on threshold/completion + if (!hasCalledOnEvents && events.length > 0) { + hasCalledOnEvents = true + onEvents(events, eosedCount >= requestCount) + } else if (eosedCount >= threshold) { onEvents(events, eosedCount >= requestCount) } }, @@ -590,7 +595,7 @@ class ClientService extends EventTarget { async function startSub() { startedCount++ - const relay = await that.pool.ensureRelay(url, { connectionTimeout: 3000 }).catch(() => { + const relay = await that.pool.ensureRelay(url, { connectionTimeout: 1500 }).catch(() => { return undefined }) // cannot connect to relay @@ -1601,7 +1606,6 @@ class ClientService extends EventTarget { // Skip localhost URLs that might be misconfigured if (url.includes('localhost:7777') || url.includes('localhost:5173')) { - console.warn(`Skipping potentially misconfigured relay: ${url}`) return false } @@ -1625,8 +1629,8 @@ class ClientService extends EventTarget { } }) - // Limit to 4 relays for better performance - return validRelays.slice(0, 4) + // Limit to 3 relays to prevent "too many concurrent REQs" errors + return validRelays.slice(0, 3) } // ================= Utils =================