Browse Source

cleaned up relay connections

imwald
Silberengel 5 months ago
parent
commit
9cb5c2ffd1
  1. 2
      src/components/NoteList/index.tsx
  2. 3
      src/components/QrCode/index.tsx
  3. 296
      src/pages/primary/DiscussionsPage/index.tsx
  4. 11
      src/providers/NotificationProvider.tsx
  5. 16
      src/services/client.service.ts

2
src/components/NoteList/index.tsx

@ -166,6 +166,8 @@ const NoteList = forwardRef(
onEvents: (events, eosed) => { onEvents: (events, eosed) => {
if (events.length > 0) { if (events.length > 0) {
setEvents(events) setEvents(events)
// Stop loading as soon as we have events, don't wait for all relays
setLoading(false)
} }
if (areAlgoRelays) { if (areAlgoRelays) {
setHasMore(false) setHasMore(false)

3
src/components/QrCode/index.tsx

@ -1,6 +1,5 @@
import QRCodeStyling from 'qr-code-styling' import QRCodeStyling from 'qr-code-styling'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import iconSvg from '../../../public/favicon.svg'
export default function QrCode({ value, size = 180 }: { value: string; size?: number }) { export default function QrCode({ value, size = 180 }: { value: string; size?: number }) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@ -13,7 +12,7 @@ export default function QrCode({ value, size = 180 }: { value: string; size?: nu
qrOptions: { qrOptions: {
errorCorrectionLevel: 'M' errorCorrectionLevel: 'M'
}, },
image: iconSvg, image: '/favicon.svg',
width: size * pixelRatio, width: size * pixelRatio,
height: size * pixelRatio, height: size * pixelRatio,
data: value, data: value,

296
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 { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' 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 { useTranslation } from 'react-i18next'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { MessageSquarePlus, Book, BookOpen } from 'lucide-react' import { MessageSquarePlus, Book, BookOpen } from 'lucide-react'
@ -42,160 +42,67 @@ const DiscussionsPage = forwardRef((_, ref) => {
const [filterBy, setFilterBy] = useState<'author' | 'subject' | 'all'>('all') 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 // 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,
// Available topic IDs for matching [pubkey, favoriteRelays]
const availableTopicIds = DISCUSSION_TOPICS.map(topic => topic.id) )
// 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 { // Memoize relay URLs with deduplication
upvotes: upvotes.length, const relayUrls = useMemo(() => {
downvotes: downvotes.length, if (selectedRelay) return [selectedRelay]
score: upvotes.length - downvotes.length, // Deduplicate and combine relays
controversy: Math.min(upvotes.length, downvotes.length) return Array.from(new Set([...availableRelays, ...FAST_READ_RELAY_URLS]))
} }, [selectedRelay, availableRelays])
} catch (error) {
console.error('Error fetching vote stats for thread', thread.id, error)
return { upvotes: 0, downvotes: 0, score: 0, controversy: 0 }
}
}
// Helper function to get vote score for a thread // Available topic IDs for matching
const getThreadVoteScore = (thread: NostrEvent) => { const availableTopicIds = useMemo(() =>
// Use custom vote stats if available (from selected relays), otherwise fall back to noteStatsService DISCUSSION_TOPICS.map(topic => topic.id),
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) // Memoize helper functions to prevent recreating on every render
if (!stats?.likes) { const getThreadVoteScore = useCallback((thread: NostrEvent) => {
console.log(`No stats for thread ${thread.id}`) const threadId = thread.id
return 0 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 upvoteReactions = stats.likes.filter(r => r.emoji === '⬆')
const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇') const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇')
const score = upvoteReactions.length - downvoteReactions.length return upvoteReactions.length - downvoteReactions.length
}, [customVoteStats])
console.log(`Thread ${thread.id}: upvotes=${upvoteReactions.length}, downvotes=${downvoteReactions.length}, score=${score} (fallback)`)
return score
}
// Helper function to get controversy score (high upvotes AND downvotes) const getThreadControversyScore = useCallback((thread: NostrEvent) => {
const getThreadControversyScore = (thread: NostrEvent) => { const threadId = thread.id
// Use custom vote stats if available (from selected relays), otherwise fall back to noteStatsService if (customVoteStats[threadId]) {
if (customVoteStats[thread.id]) { return customVoteStats[threadId].controversy
const stats = customVoteStats[thread.id]
console.log(`Thread ${thread.id}: upvotes=${stats.upvotes}, downvotes=${stats.downvotes}, controversy=${stats.controversy} (custom)`)
return stats.controversy
} }
const stats = noteStatsService.getNoteStats(threadId)
const stats = noteStatsService.getNoteStats(thread.id) if (!stats?.likes) return 0
if (!stats?.likes) {
console.log(`No stats for thread ${thread.id}`)
return 0
}
const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆') const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆')
const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇') const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇')
const balance = Math.min(upvoteReactions.length, downvoteReactions.length)
const magnitude = upvoteReactions.length + downvoteReactions.length
return balance * magnitude
}, [customVoteStats])
// Controversy = minimum of upvotes and downvotes (both need to be high) const getThreadZapAmount = useCallback((thread: NostrEvent) => {
const controversy = Math.min(upvoteReactions.length, downvoteReactions.length)
console.log(`Thread ${thread.id}: upvotes=${upvoteReactions.length}, downvotes=${downvoteReactions.length}, controversy=${controversy} (fallback)`)
return controversy
}
// Helper function to get total zap amount for a thread
const getThreadZapAmount = (thread: NostrEvent) => {
const stats = noteStatsService.getNoteStats(thread.id) const stats = noteStatsService.getNoteStats(thread.id)
if (!stats?.zaps) { if (!stats?.zaps) {
return 0 return 0
} }
const totalAmount = stats.zaps.reduce((sum, zap) => sum + zap.amount, 0) const totalAmount = stats.zaps.reduce((sum, zap) => sum + zap.amount, 0)
console.log(`Thread ${thread.id}: ${stats.zaps.length} zaps, total amount: ${totalAmount}`) console.log(`Thread ${thread.id}: ${stats.zaps.length} zaps, total amount: ${totalAmount}`)
return 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<string, { upvotes: number; downvotes: number; score: number; controversy: number }> = {}
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) setLoading(true)
setCustomVoteStats({}) // Clear custom stats when fetching
try { try {
// Filter by relay if selected, otherwise use all available relays plus fast read relays // Fetch all kind 11 events (limit 100, newest first)
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
console.log('Fetching kind 11 events from relays:', relayUrls) console.log('Fetching kind 11 events from relays:', relayUrls)
// Fetch recent kind 11 events (last 30 days) // Fetch recent kind 11 events (last 30 days)
const thirtyDaysAgo = Math.floor((Date.now() - (30 * 24 * 60 * 60 * 1000)) / 1000) const thirtyDaysAgo = Math.floor((Date.now() - (30 * 24 * 60 * 60 * 1000)) / 1000)
@ -207,7 +114,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
limit: 100 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 // Debug: Show date range of fetched events
if (events.length > 0) { if (events.length > 0) {
@ -215,29 +122,10 @@ const DiscussionsPage = forwardRef((_, ref) => {
const newest = new Date(Math.max(...dates.map(d => d.getTime()))) const newest = new Date(Math.max(...dates.map(d => d.getTime())))
const oldest = new Date(Math.min(...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(`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`) 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 const validThreads = events
.filter(event => { .filter(event => {
// Ensure it has a title tag // Ensure it has a title tag
@ -246,28 +134,83 @@ const DiscussionsPage = forwardRef((_, ref) => {
}) })
.map(event => ({ .map(event => ({
...event, ...event,
_relaySource: selectedRelay || 'multiple' // Track which relay(s) it was found on _relaySource: selectedRelay || 'multiple'
})) }))
setAllThreads(validThreads) 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) { } catch (error) {
console.error('Error fetching threads:', error) console.error('Error fetching threads:', error)
setAllThreads([]) setAllThreads([])
} finally { } finally {
setLoading(false) 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<string, { upvotes: number; downvotes: number; score: number; controversy: number }> = {}
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 => { const categorizedThreads = allThreads.map(thread => {
// Find all 't' tags in the thread // Find all 't' tags in the thread
const topicTags = thread.tags.filter(tag => tag[0] === 't' && tag[1]) const topicTags = thread.tags.filter(tag => tag[0] === 't' && tag[1])
@ -462,10 +405,23 @@ const DiscussionsPage = forwardRef((_, ref) => {
setThreads(threadsForTopic) setThreads(threadsForTopic)
setGroupedThreads({}) // Clear grouped threads setGroupedThreads({}) // Clear grouped threads
} }
} }, [
allThreads,
availableTopicIds,
selectedTopic,
selectedSubtopic,
selectedSort,
viewMode,
searchQuery,
filterBy,
customVoteStats,
getThreadVoteScore,
getThreadControversyScore,
getThreadZapAmount
])
// Helper function to sort threads // Helper function to sort threads
const sortThreads = (threadsToSort: NostrEvent[]) => { const sortThreads = useCallback((threadsToSort: NostrEvent[]) => {
const sortedThreads = [...threadsToSort] const sortedThreads = [...threadsToSort]
switch (selectedSort) { switch (selectedSort) {
@ -490,7 +446,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
default: default:
return sortedThreads.sort((a, b) => b.created_at - a.created_at) return sortedThreads.sort((a, b) => b.created_at - a.created_at)
} }
} }, [selectedSort, getThreadVoteScore, getThreadControversyScore])
const handleCreateThread = () => { const handleCreateThread = () => {
setShowCreateThread(true) setShowCreateThread(true)

11
src/providers/NotificationProvider.tsx

@ -99,7 +99,6 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
let eosed = false let eosed = false
const relayList = await client.fetchRelayList(pubkey) const relayList = await client.fetchRelayList(pubkey)
const notificationRelays = relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS 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( const subCloser = client.subscribe(
notificationRelays, notificationRelays,
[ [
@ -130,16 +129,6 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
}, },
onevent: (evt) => { onevent: (evt) => {
if (evt.pubkey !== pubkey) { 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) => { setNewNotifications((prev) => {
if (!eosed) { if (!eosed) {

16
src/services/client.service.ts

@ -61,7 +61,7 @@ class ClientService extends EventTarget {
) )
private trendingNotesCache: NEvent[] | null = null private trendingNotesCache: NEvent[] | null = null
private requestThrottle = new Map<string, number>() // Track request timestamps per relay private requestThrottle = new Map<string, number>() // 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<string, number>() // Track consecutive failures per relay private failureCount = new Map<string, number>() // Track consecutive failures per relay
private readonly MAX_FAILURES = 3 // Max failures before exponential backoff private readonly MAX_FAILURES = 3 // Max failures before exponential backoff
private circuitBreaker = new Map<string, number>() // Track when relays are temporarily disabled private circuitBreaker = new Map<string, number>() // Track when relays are temporarily disabled
@ -475,6 +475,7 @@ class ClientService extends EventTarget {
let eventIdSet = new Set<string>() let eventIdSet = new Set<string>()
let events: NEvent[] = [] let events: NEvent[] = []
let eosedCount = 0 let eosedCount = 0
let hasCalledOnEvents = false
const subs = await Promise.all( const subs = await Promise.all(
subRequests.map(async ({ urls, filter }) => { 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) events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
eventIdSet = new Set(events.map((evt) => evt.id)) 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) onEvents(events, eosedCount >= requestCount)
} }
}, },
@ -590,7 +595,7 @@ class ClientService extends EventTarget {
async function startSub() { async function startSub() {
startedCount++ startedCount++
const relay = await that.pool.ensureRelay(url, { connectionTimeout: 3000 }).catch(() => { const relay = await that.pool.ensureRelay(url, { connectionTimeout: 1500 }).catch(() => {
return undefined return undefined
}) })
// cannot connect to relay // cannot connect to relay
@ -1601,7 +1606,6 @@ class ClientService extends EventTarget {
// Skip localhost URLs that might be misconfigured // Skip localhost URLs that might be misconfigured
if (url.includes('localhost:7777') || url.includes('localhost:5173')) { if (url.includes('localhost:7777') || url.includes('localhost:5173')) {
console.warn(`Skipping potentially misconfigured relay: ${url}`)
return false return false
} }
@ -1625,8 +1629,8 @@ class ClientService extends EventTarget {
} }
}) })
// Limit to 4 relays for better performance // Limit to 3 relays to prevent "too many concurrent REQs" errors
return validRelays.slice(0, 4) return validRelays.slice(0, 3)
} }
// ================= Utils ================= // ================= Utils =================

Loading…
Cancel
Save