import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' 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' import ThreadCard from '@/pages/primary/DiscussionsPage/ThreadCard' import TopicFilter from '@/pages/primary/DiscussionsPage/TopicFilter' import ThreadSort, { SortOption } from '@/pages/primary/DiscussionsPage/ThreadSort' import CreateThreadDialog, { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/CreateThreadDialog' import ViewToggle from '@/pages/primary/DiscussionsPage/ViewToggle' import { NostrEvent } from 'nostr-tools' import client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import { useSecondaryPage } from '@/PageManager' import { toNote } from '@/lib/link' import { kinds } from 'nostr-tools' const DiscussionsPage = forwardRef((_, ref) => { const { t } = useTranslation() const { favoriteRelays, relaySets } = useFavoriteRelays() const { pubkey } = useNostr() const { push } = useSecondaryPage() const [selectedTopic, setSelectedTopic] = useState('all') const [selectedSubtopic, setSelectedSubtopic] = useState(null) const [selectedRelay, setSelectedRelay] = useState(null) const [selectedSort, setSelectedSort] = useState('newest') const [allThreads, setAllThreads] = useState([]) const [threads, setThreads] = useState([]) const [loading, setLoading] = useState(false) const [showCreateThread, setShowCreateThread] = useState(false) const [statsLoaded, setStatsLoaded] = useState(false) const [customVoteStats, setCustomVoteStats] = useState>({}) const [viewMode, setViewMode] = useState<'flat' | 'grouped'>('grouped') const [groupedThreads, setGroupedThreads] = useState>({}) // Search and filter state for readings const [searchQuery, setSearchQuery] = useState('') 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 = useMemo(() => pubkey && favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS, [pubkey, favoriteRelays] ) // Memoize relay URLs with deduplication const relayUrls = useMemo(() => { if (selectedRelay) { // Check if it's a relay set const relaySet = relaySets.find(set => set.id === selectedRelay) if (relaySet) { return relaySet.relayUrls } // It's an individual relay return [selectedRelay] } // Deduplicate and combine relays return Array.from(new Set([...availableRelays, ...FAST_READ_RELAY_URLS])) }, [selectedRelay, availableRelays, relaySets]) // Available topic IDs for matching const availableTopicIds = useMemo(() => DISCUSSION_TOPICS.map(topic => topic.id), [] ) // 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 === '⬇️') return upvoteReactions.length - downvoteReactions.length }, [customVoteStats]) const getThreadControversyScore = useCallback((thread: NostrEvent) => { const threadId = thread.id if (customVoteStats[threadId]) { return customVoteStats[threadId].controversy } 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 balance = Math.min(upvoteReactions.length, downvoteReactions.length) const magnitude = upvoteReactions.length + downvoteReactions.length return balance * magnitude }, [customVoteStats]) 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) return totalAmount }, []) // Memoize fetchAllThreads to prevent recreating on every render const fetchAllThreads = useCallback(async () => { setLoading(true) setCustomVoteStats({}) // Clear custom stats when fetching try { // 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) const events = await client.fetchEvents(relayUrls, [ { kinds: [11], // Thread events since: thirtyDaysAgo, // Only fetch events from last 30 days limit: 100 } ]) // Debug: Show date range of fetched events // Filter and sort threads const validThreads = events .filter(event => { // Ensure it has a title tag const titleTag = event.tags.find(tag => tag[0] === 'title' && tag[1]) return titleTag && event.content.trim().length > 0 }) .map(event => { // Get the relay where this event was actually found const eventHints = client.getEventHints(event.id) const relaySource = eventHints.length > 0 ? eventHints[0] : 'unknown' return { ...event, _relaySource: relaySource } }) setAllThreads(validThreads) } catch (error) { console.error('Error fetching threads:', error) setAllThreads([]) } finally { setLoading(false) } }, [relayUrls, selectedRelay, selectedSort, pubkey]) useEffect(() => { fetchAllThreads() }, [fetchAllThreads]) useEffect(() => { // Only wait for stats for vote-based sorting if ((selectedSort === 'top' || selectedSort === 'controversial') && !statsLoaded) { return } 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) // 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) }).catch((error) => { console.error('Error fetching vote stats:', error) setStatsLoaded(true) }) } else { setStatsLoaded(true) // For non-vote-based sorting, stats don't matter } }, [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]) // Find the first matching topic from our available topics let matchedTopic = 'general' // Default to general let isReadingGroup = false for (const topicTag of topicTags) { if (availableTopicIds.includes(topicTag[1])) { matchedTopic = topicTag[1] break // Use the first match found } } // Check if this is a reading group thread if (matchedTopic === 'literature') { const readingsTag = thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings') isReadingGroup = !!readingsTag } return { ...thread, _categorizedTopic: matchedTopic, _isReadingGroup: isReadingGroup } }) // Filter threads for the selected topic (or show all if "all" is selected) let threadsForTopic = selectedTopic === 'all' ? categorizedThreads.map(thread => { // Remove the temporary categorization property but keep relay source const { _categorizedTopic, _isReadingGroup, ...cleanThread } = thread return cleanThread }) : categorizedThreads .filter(thread => { if (thread._categorizedTopic !== selectedTopic) return false // Handle subtopic filtering for literature if (selectedTopic === 'literature' && selectedSubtopic) { if (selectedSubtopic === 'readings') { return thread._isReadingGroup } else if (selectedSubtopic === 'general') { return !thread._isReadingGroup } } return true }) .map(thread => { // Remove the temporary categorization property but keep relay source const { _categorizedTopic, _isReadingGroup, ...cleanThread } = thread return cleanThread }) // Apply search and filter for readings (handled in display logic) // Apply sorting based on selectedSort switch (selectedSort) { case 'newest': // Create a new sorted array instead of mutating const sortedNewest = [...threadsForTopic].sort((a, b) => { const result = b.created_at - a.created_at return result }) // Replace the original array threadsForTopic.length = 0 threadsForTopic.push(...sortedNewest) break case 'oldest': // Create a new sorted array instead of mutating const sortedOldest = [...threadsForTopic].sort((a, b) => a.created_at - b.created_at) // Replace the original array threadsForTopic.length = 0 threadsForTopic.push(...sortedOldest) break case 'top': // Sort by vote score (upvotes - downvotes), then by newest if tied const sortedTop = [...threadsForTopic].sort((a, b) => { const scoreA = getThreadVoteScore(a) const scoreB = getThreadVoteScore(b) if (scoreA !== scoreB) { return scoreB - scoreA // Higher scores first } return b.created_at - a.created_at // Newest first if tied }) // Replace the original array threadsForTopic.length = 0 threadsForTopic.push(...sortedTop) break case 'controversial': // Sort by controversy score (min of upvotes and downvotes), then by newest if tied const sortedControversial = [...threadsForTopic].sort((a, b) => { const controversyA = getThreadControversyScore(a) const controversyB = getThreadControversyScore(b) if (controversyA !== controversyB) { return controversyB - controversyA // Higher controversy first } return b.created_at - a.created_at // Newest first if tied }) // Replace the original array threadsForTopic.length = 0 threadsForTopic.push(...sortedControversial) break case 'most-zapped': // Sort by total zap amount, then by newest if tied const sortedMostZapped = [...threadsForTopic].sort((a, b) => { const zapAmountA = getThreadZapAmount(a) const zapAmountB = getThreadZapAmount(b) if (zapAmountA !== zapAmountB) { return zapAmountB - zapAmountA // Higher zap amounts first } return b.created_at - a.created_at // Newest first if tied }) // Replace the original array threadsForTopic.length = 0 threadsForTopic.push(...sortedMostZapped) break default: const sortedDefault = [...threadsForTopic].sort((a, b) => b.created_at - a.created_at) threadsForTopic.length = 0 threadsForTopic.push(...sortedDefault) } // If grouped view and showing all topics, group threads by topic if (viewMode === 'grouped' && selectedTopic === 'all') { // Group threads by topic const groupedThreads = categorizedThreads.reduce((groups, thread) => { const topic = thread._categorizedTopic if (!groups[topic]) { groups[topic] = [] } // Remove the temporary categorization property but keep relay source const { _categorizedTopic, ...cleanThread } = thread groups[topic].push(cleanThread) return groups }, {} as Record) // Sort threads within each group Object.keys(groupedThreads).forEach(topic => { groupedThreads[topic] = sortThreads(groupedThreads[topic]) }) // Sort groups by the newest thread in each group const sortedGroupedThreads = Object.fromEntries( Object.entries(groupedThreads) .sort(([, threadsA], [, threadsB]) => { // Get the newest thread from each group const newestA = threadsA[0]?.created_at || 0 // First thread is newest after sorting const newestB = threadsB[0]?.created_at || 0 return newestB - newestA // Newest groups first }) ) // Store grouped data in a different state setGroupedThreads(sortedGroupedThreads) setThreads([]) // Clear flat threads } else { // Flat view or specific topic selected 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 = useCallback((threadsToSort: NostrEvent[]) => { const sortedThreads = [...threadsToSort] switch (selectedSort) { case 'newest': return sortedThreads.sort((a, b) => b.created_at - a.created_at) case 'oldest': return sortedThreads.sort((a, b) => a.created_at - b.created_at) case 'top': return sortedThreads.sort((a, b) => { const scoreA = getThreadVoteScore(a) const scoreB = getThreadVoteScore(b) if (scoreA !== scoreB) return scoreB - scoreA return b.created_at - a.created_at }) case 'controversial': return sortedThreads.sort((a, b) => { const controversyA = getThreadControversyScore(a) const controversyB = getThreadControversyScore(b) if (controversyA !== controversyB) return controversyB - controversyA return b.created_at - a.created_at }) default: return sortedThreads.sort((a, b) => b.created_at - a.created_at) } }, [selectedSort, getThreadVoteScore, getThreadControversyScore]) const handleCreateThread = () => { setShowCreateThread(true) } const handleThreadCreated = () => { setShowCreateThread(false) fetchAllThreads() // Refresh all threads } return (
{ setSelectedTopic(topic) setSelectedSubtopic(null) // Reset subtopic when changing topic }} threads={viewMode === 'grouped' && selectedTopic === 'all' ? allThreads : threads} replies={[]} /> {(availableRelays.length > 1 || relaySets.length > 0) && ( setSelectedRelay(null)}> All Relays {relaySets.map(relaySet => ( setSelectedRelay(relaySet.id)} > {relaySet.name} ))} {availableRelays.map(relay => ( setSelectedRelay(relay)} > {relay.replace('wss://', '').replace('ws://', '')} ))} )}
} displayScrollToTopButton >

{t('Discussions')} - {selectedTopic === 'all' ? t('All Topics') : DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label}

{selectedTopic === 'all' && ( )}
{loading ? (
{t('Loading threads...')}
) : selectedTopic === 'literature' ? (
{/* General Literature and Arts Section */}

{t('General Topics')}

({threads.filter(thread => !thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).length} {threads.filter(thread => !thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).length === 1 ? t('thread') : t('threads')})
{threads.filter(thread => !thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).map(thread => ( { push(toNote(thread)) }} /> ))}
{/* Readings Section */}

{t('Readings')}

({threads.filter(thread => thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).length} {threads.filter(thread => thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).length === 1 ? t('thread') : t('threads')})
{/* Readings-specific search and filter */}
setSearchQuery(e.target.value)} placeholder={t('Search by author or book...')} className="px-3 h-10 rounded border bg-background text-sm w-48" />
{threads .filter(thread => thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')) .filter(thread => { if (!searchQuery.trim()) return true const authorTag = thread.tags.find(tag => tag[0] === 'author') const subjectTag = thread.tags.find(tag => tag[0] === 'subject') if (filterBy === 'author' && authorTag) { return authorTag[1].toLowerCase().includes(searchQuery.toLowerCase()) } else if (filterBy === 'subject' && subjectTag) { return subjectTag[1].toLowerCase().includes(searchQuery.toLowerCase()) } else if (filterBy === 'all') { const authorMatch = authorTag && authorTag[1].toLowerCase().includes(searchQuery.toLowerCase()) const subjectMatch = subjectTag && subjectTag[1].toLowerCase().includes(searchQuery.toLowerCase()) return authorMatch || subjectMatch } return false }) .map(thread => ( { push(toNote(thread)) }} /> ))}
) : (viewMode === 'grouped' && selectedTopic === 'all' ? Object.keys(groupedThreads).length === 0 : threads.length === 0) ? (

{t('No threads yet')}

{selectedTopic === 'all' ? t('No discussion threads found. Try refreshing or check your relay connection.') : t('Be the first to start a discussion in this topic!') }

) : viewMode === 'grouped' && selectedTopic === 'all' ? (
{Object.entries(groupedThreads).length === 0 && (
Debug: No grouped threads found. groupedThreads keys: {Object.keys(groupedThreads).join(', ')}
)} {Object.entries(groupedThreads).map(([topicId, topicThreads]) => { const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topicId) if (!topicInfo || topicThreads.length === 0) return null return (

{topicInfo.label}

({topicThreads.length} {topicThreads.length === 1 ? t('thread') : t('threads')})
{topicThreads.map(thread => ( { push(toNote(thread)) }} /> ))}
) })}
) : (
{threads.map(thread => ( { push(toNote(thread)) }} /> ))}
)}
{showCreateThread && ( setShowCreateThread(false)} onThreadCreated={handleThreadCreated} /> )}
) }) DiscussionsPage.displayName = 'DiscussionsPage' export default DiscussionsPage