You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

708 lines
28 KiB

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<string | null>(null)
const [selectedRelay, setSelectedRelay] = useState<string | null>(null)
const [selectedSort, setSelectedSort] = useState<SortOption>('newest')
const [allThreads, setAllThreads] = useState<NostrEvent[]>([])
const [threads, setThreads] = useState<NostrEvent[]>([])
const [loading, setLoading] = useState(false)
const [showCreateThread, setShowCreateThread] = useState(false)
const [statsLoaded, setStatsLoaded] = useState(false)
const [customVoteStats, setCustomVoteStats] = useState<Record<string, { upvotes: number; downvotes: number; score: number; controversy: number }>>({})
const [viewMode, setViewMode] = useState<'flat' | 'grouped'>('grouped')
const [groupedThreads, setGroupedThreads] = useState<Record<string, NostrEvent[]>>({})
// 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<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)
}).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<string, NostrEvent[]>)
// 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 (
<PrimaryPageLayout
ref={ref}
pageName="discussions"
titlebar={
<div className="flex gap-1 items-center h-full justify-between">
<div className="flex gap-1 items-center">
<TopicFilter
topics={DISCUSSION_TOPICS}
selectedTopic={selectedTopic}
onTopicChange={(topic) => {
setSelectedTopic(topic)
setSelectedSubtopic(null) // Reset subtopic when changing topic
}}
threads={viewMode === 'grouped' && selectedTopic === 'all' ? allThreads : threads}
replies={[]}
/>
{(availableRelays.length > 1 || relaySets.length > 0) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-10 text-sm">
{selectedRelay ? (
relaySets.find(set => set.id === selectedRelay)?.name ||
selectedRelay.replace('wss://', '').replace('ws://', '')
) : (
'All Relays'
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setSelectedRelay(null)}>
All Relays
</DropdownMenuItem>
<DropdownMenuSeparator />
{relaySets.map(relaySet => (
<DropdownMenuItem
key={relaySet.id}
onClick={() => setSelectedRelay(relaySet.id)}
>
{relaySet.name}
</DropdownMenuItem>
))}
{availableRelays.map(relay => (
<DropdownMenuItem
key={relay}
onClick={() => setSelectedRelay(relay)}
>
{relay.replace('wss://', '').replace('ws://', '')}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex gap-1 items-center">
<Button
variant="ghost"
size="titlebar-icon"
onClick={handleCreateThread}
title={t('Create new thread')}
>
<MessageSquarePlus />
</Button>
</div>
</div>
}
displayScrollToTopButton
>
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">
{t('Discussions')} - {selectedTopic === 'all' ? t('All Topics') : DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label}
</h1>
<div className="flex items-center gap-2">
{selectedTopic === 'all' && (
<ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
)}
<ThreadSort
selectedSort={selectedSort}
onSortChange={setSelectedSort}
/>
</div>
</div>
{loading ? (
<div className="flex justify-center py-8">
<div className="text-muted-foreground">{t('Loading threads...')}</div>
</div>
) : selectedTopic === 'literature' ? (
<div className="space-y-6">
{/* General Literature and Arts Section */}
<div className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b">
<BookOpen className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t('General Topics')}</h2>
<span className="text-sm text-muted-foreground">
({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')})
</span>
</div>
<div className="space-y-3">
{threads.filter(thread => !thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).map(thread => (
<ThreadCard
key={thread.id}
thread={thread}
onThreadClick={() => {
push(toNote(thread))
}}
/>
))}
</div>
</div>
{/* Readings Section */}
<div className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b">
<Book className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t('Readings')}</h2>
<span className="text-sm text-muted-foreground">
({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')})
</span>
</div>
{/* Readings-specific search and filter */}
<div className="flex gap-2 items-center p-3 bg-muted/30 rounded-lg">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('Search by author or book...')}
className="px-3 h-10 rounded border bg-background text-sm w-48"
/>
<select
value={filterBy}
onChange={(e) => setFilterBy(e.target.value as 'author' | 'subject' | 'all')}
className="px-3 h-10 rounded border bg-background text-sm"
>
<option value="all">{t('All')}</option>
<option value="author">{t('Author')}</option>
<option value="subject">{t('Subject')}</option>
</select>
</div>
<div className="space-y-3">
{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 => (
<ThreadCard
key={thread.id}
thread={thread}
onThreadClick={() => {
push(toNote(thread))
}}
/>
))}
</div>
</div>
</div>
) : (viewMode === 'grouped' && selectedTopic === 'all' ?
Object.keys(groupedThreads).length === 0 :
threads.length === 0) ? (
<Card>
<CardContent className="p-8 text-center">
<MessageSquarePlus className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2">{t('No threads yet')}</h3>
<p className="text-muted-foreground mb-4">
{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!')
}
</p>
<div className="flex gap-2 justify-center">
<Button onClick={handleCreateThread}>
<MessageSquarePlus className="w-4 h-4 mr-2" />
{t('Create Thread')}
</Button>
<Button variant="outline" onClick={fetchAllThreads}>
{t('Refresh')}
</Button>
</div>
</CardContent>
</Card>
) : viewMode === 'grouped' && selectedTopic === 'all' ? (
<div className="space-y-6">
{Object.entries(groupedThreads).length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Debug: No grouped threads found. groupedThreads keys: {Object.keys(groupedThreads).join(', ')}
</div>
)}
{Object.entries(groupedThreads).map(([topicId, topicThreads]) => {
const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topicId)
if (!topicInfo || topicThreads.length === 0) return null
return (
<div key={topicId} className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b">
<topicInfo.icon className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{topicInfo.label}</h2>
<span className="text-sm text-muted-foreground">
({topicThreads.length} {topicThreads.length === 1 ? t('thread') : t('threads')})
</span>
</div>
<div className="space-y-3">
{topicThreads.map(thread => (
<ThreadCard
key={thread.id}
thread={thread}
onThreadClick={() => {
push(toNote(thread))
}}
/>
))}
</div>
</div>
)
})}
</div>
) : (
<div className="space-y-3">
{threads.map(thread => (
<ThreadCard
key={thread.id}
thread={thread}
onThreadClick={() => {
push(toNote(thread))
}}
/>
))}
</div>
)}
</div>
{showCreateThread && (
<CreateThreadDialog
topic={selectedTopic}
availableRelays={availableRelays}
selectedRelay={selectedRelay}
onClose={() => setShowCreateThread(false)}
onThreadCreated={handleThreadCreated}
/>
)}
</PrimaryPageLayout>
)
})
DiscussionsPage.displayName = 'DiscussionsPage'
export default DiscussionsPage