diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx index d864916..7e13d82 100644 --- a/src/pages/primary/DiscussionsPage/index.tsx +++ b/src/pages/primary/DiscussionsPage/index.tsx @@ -5,7 +5,6 @@ import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' -import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { forwardRef, useEffect, useState, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' @@ -19,7 +18,6 @@ import SubtopicFilter from '@/pages/primary/DiscussionsPage/SubtopicFilter' import TopicSubscribeButton from '@/components/TopicSubscribeButton' import { NostrEvent } from 'nostr-tools' import client from '@/services/client.service' -import noteStatsService from '@/services/note-stats.service' import storage from '@/services/local-storage.service' import { useSecondaryPage } from '@/PageManager' import { toNote } from '@/lib/link' @@ -27,130 +25,48 @@ import { kinds } from 'nostr-tools' import { analyzeThreadTopics, getCategorizedTopic, - getDynamicSubtopics, - extractAllTopics + getDynamicSubtopics } from '@/lib/discussion-topics' -import { userIdToPubkey, pubkeyToNpub } from '@/lib/pubkey' -/** - * Check if a thread matches the search query - * Searches: title, content, t-tags, hashtags, author, subject, and pubkey (supports npub) - */ -function threadMatchesSearch(thread: NostrEvent, query: string): boolean { - if (!query.trim()) return true - - const lowerQuery = query.toLowerCase().trim() - - // Check if query is an npub and convert to pubkey - let searchPubkey: string | null = null - if (lowerQuery.startsWith('npub1')) { - searchPubkey = userIdToPubkey(query.trim()) - } - - // Search by pubkey (exact match or if query is npub) - if (searchPubkey && thread.pubkey === searchPubkey) { - return true - } - - // Also check if the thread's pubkey matches when encoded as npub - const threadNpub = pubkeyToNpub(thread.pubkey) - if (threadNpub && threadNpub.toLowerCase().includes(lowerQuery)) { - return true - } - - // Search in title (from 'title' tag) - const titleTag = thread.tags.find(tag => tag[0] === 'title') - if (titleTag && titleTag[1]?.toLowerCase().includes(lowerQuery)) { - return true - } - - // Search in author (from 'author' tag - used in reading groups) - const authorTag = thread.tags.find(tag => tag[0] === 'author') - if (authorTag && authorTag[1]?.toLowerCase().includes(lowerQuery)) { - return true - } - - // Search in subject/book (from 'subject' tag - used in reading groups) - const subjectTag = thread.tags.find(tag => tag[0] === 'subject') - if (subjectTag && subjectTag[1]?.toLowerCase().includes(lowerQuery)) { - return true - } - - // Search in content - if (thread.content.toLowerCase().includes(lowerQuery)) { - return true - } - - // Search in t-tags - const tTags = thread.tags.filter(tag => tag[0] === 't') - for (const tag of tTags) { - if (tag[1]?.toLowerCase().includes(lowerQuery)) { - return true - } - } - - // Search in hashtags from content - const allTopics = extractAllTopics(thread) - for (const topic of allTopics) { - if (topic.toLowerCase().includes(lowerQuery)) { - return true - } - } - - return false +// Simple event map type +type EventMapEntry = { + event: NostrEvent + relaySources: string[] + tTags: string[] + hashtags: string[] + allTopics: string[] + categorizedTopic: string } const DiscussionsPage = forwardRef((_, ref) => { const { t } = useTranslation() - const { favoriteRelays, relaySets } = useFavoriteRelays() + const { relaySets } = useFavoriteRelays() const { pubkey } = useNostr() - const { isEventDeleted } = useDeletedEvent() const { push } = useSecondaryPage() + + // State management 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 [eventMap, setEventMap] = useState>(new Map()) + const [filteredEvents, setFilteredEvents] = useState([]) + const [loading, setLoading] = useState(true) 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 + const [groupedEvents, setGroupedEvents] = useState>({}) const [searchQuery, setSearchQuery] = useState('') // Topic analysis for dynamic subtopics const [topicAnalysis, setTopicAnalysis] = useState>(new Map()) const [availableSubtopics, setAvailableSubtopics] = useState([]) - // 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] - ) - - // State for relay URLs - const [relayUrls, setRelayUrls] = useState([]) + // State for all available relays + const [allRelays, setAllRelays] = useState([]) - // Update relay URLs when dependencies change + // Get all available relays (always use all relays for building the map) useEffect(() => { - const updateRelayUrls = async () => { - if (selectedRelay) { - // Check if it's a relay set - const relaySet = relaySets.find(set => set.id === selectedRelay) - if (relaySet) { - setRelayUrls(relaySet.relayUrls) - return - } - // It's an individual relay - setRelayUrls([selectedRelay]) - return - } - - // For "All Relays", include user's write relays and stored relay sets too + const updateRelays = async () => { let userWriteRelays: string[] = [] let storedRelaySetRelays: string[] = [] @@ -160,8 +76,7 @@ const DiscussionsPage = forwardRef((_, ref) => { const relayList = await client.fetchRelayList(pubkey) userWriteRelays = relayList?.write || [] - // Get relays from stored relay sets (additional safety check) - // Note: favoriteRelays should already include these, but let's be thorough + // Get relays from stored relay sets const storedRelaySets = storage.getRelaySets() storedRelaySetRelays = storedRelaySets.flatMap(set => set.relayUrls) } catch (error) { @@ -169,19 +84,19 @@ const DiscussionsPage = forwardRef((_, ref) => { } } - // Normalize and deduplicate all relays: favorite relays, user write relays, stored relay sets, and fast read relays - const allRelays = Array.from(new Set([ - ...availableRelays.map(url => normalizeUrl(url) || url), + // Normalize and deduplicate all relays + const relays = Array.from(new Set([ + ...DEFAULT_FAVORITE_RELAYS.map(url => normalizeUrl(url) || url), ...userWriteRelays.map(url => normalizeUrl(url) || url), ...storedRelaySetRelays.map(url => normalizeUrl(url) || url), ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) ])) - setRelayUrls(allRelays) + setAllRelays(relays) } - updateRelayUrls() - }, [selectedRelay, availableRelays, relaySets, pubkey]) + updateRelays() + }, [pubkey]) // Available topic IDs for matching const availableTopicIds = useMemo(() => @@ -189,420 +104,269 @@ const DiscussionsPage = forwardRef((_, ref) => { [] ) - // 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 () => { + // Fetch all kind 11 events from all relays + const fetchAllEvents = useCallback(async () => { setLoading(true) - setCustomVoteStats({}) // Clear custom stats when fetching try { - // Fetch all kind 11 events (limit 100, newest first) // 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, [ + const events = await client.fetchEvents(allRelays, [ { kinds: [11], // Thread events - since: thirtyDaysAgo, // Only fetch events from last 30 days + since: thirtyDaysAgo, limit: 100 } ]) - // Filter and sort threads - const validThreads = events - .filter(event => { - // Filter out deleted events - if (isEventDeleted(event)) { - console.log(`Filtering out deleted event: ${event.id}`) - return false - } - - // 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, isEventDeleted]) - - useEffect(() => { - fetchAllThreads() - }, [fetchAllThreads]) - - // Analyze topics whenever threads change - useEffect(() => { - if (allThreads.length > 0) { - const analysis = analyzeThreadTopics(allThreads, availableTopicIds) - setTopicAnalysis(analysis) - } else { - setTopicAnalysis(new Map()) - } - }, [allThreads, availableTopicIds]) - - // Update available subtopics when topic analysis or selected topic changes - useEffect(() => { - if (selectedTopic && selectedTopic !== 'all') { - const subtopics = getDynamicSubtopics(topicAnalysis.get(selectedTopic), 3) + // Create a map of events with their relay sources + const newEventMap = new Map() - // Special case: Always include 'readings' as a subtopic for 'literature' - if (selectedTopic === 'literature' && !subtopics.includes('readings')) { - subtopics.unshift('readings') // Add at the beginning - } + events.forEach(event => { + const eventHints = client.getEventHints(event.id) + const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] + + if (newEventMap.has(event.id)) { + // Event already exists, add relay sources + const existing = newEventMap.get(event.id)! + existing.relaySources = [...new Set([...existing.relaySources, ...relaySources])] + } else { + // New event + newEventMap.set(event.id, { event, relaySources }) + } + }) - setAvailableSubtopics(subtopics) - } else { - setAvailableSubtopics([]) - } - }, [topicAnalysis, selectedTopic]) - - useEffect(() => { - // Only wait for stats for vote-based sorting - if ((selectedSort === 'top' || selectedSort === 'controversial') && !statsLoaded) { - return - } - filterThreadsByTopic() - }, [allThreads, selectedTopic, selectedSubtopic, selectedSort, statsLoaded, viewMode, searchQuery]) - - // Fetch stats when sort changes to top/controversial - useEffect(() => { - if ((selectedSort === 'top' || selectedSort === 'controversial') && allThreads.length > 0) { - setStatsLoaded(false) + // Get all event IDs to check for deletions + const eventIds = Array.from(newEventMap.keys()) - // Use the same relay selection as thread fetching - const relayUrls = selectedRelay ? [selectedRelay] : availableRelays + // Fetch deletion events for these specific event IDs + let deletedEventIds = new Set() + if (eventIds.length > 0) { + try { + const deletionEvents = await client.fetchEvents(allRelays, [ + { + kinds: [kinds.EventDeletion], + '#e': eventIds, + since: thirtyDaysAgo, + limit: 1000 + } + ]) + + // Extract deleted event IDs + deletionEvents.forEach(deletionEvent => { + const deletedEventTags = deletionEvent.tags.filter(tag => tag[0] === 'e' && tag[1]) + deletedEventTags.forEach(tag => { + if (tag[1] && eventIds.includes(tag[1])) { + deletedEventIds.add(tag[1]) + } + }) + }) + } catch (error) { + console.warn('Failed to fetch deletion events:', error) + } + } - // Fetch ALL reactions in a single batch request instead of per-thread - const threadIds = allThreads.map(t => t.id) + // Build the final event map with topic information + const finalEventMap = new Map() - client.fetchEvents(relayUrls, [ - { - '#e': threadIds, - kinds: [kinds.Reaction], - limit: 500 + newEventMap.forEach(({ event, relaySources }, eventId) => { + // Skip deleted events + if (deletedEventIds.has(eventId)) { + return } - ]).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) - } - }) + // Extract topics + const tTags = event.tags.filter(tag => tag[0] === 't' && tag[1]).map(tag => tag[1]) + const hashtags = (event.content.match(/#\w+/g) || []).map(tag => tag.slice(1)) + const allTopics = [...new Set([...tTags, ...hashtags])] + const categorizedTopic = getCategorizedTopic(event, availableTopicIds) - setCustomVoteStats(newCustomStats) - setStatsLoaded(true) - }).catch((error) => { - console.error('Error fetching vote stats:', error) - setStatsLoaded(true) + finalEventMap.set(eventId, { + event, + relaySources, + tTags, + hashtags, + allTopics, + categorizedTopic + }) }) - } else { - setStatsLoaded(true) // For non-vote-based sorting, stats don't matter + + setEventMap(finalEventMap) + } catch (error) { + console.error('Error fetching events:', error) + setEventMap(new Map()) + } finally { + setLoading(false) } - }, [selectedSort, allThreads, selectedRelay, availableRelays]) + }, [allRelays, availableTopicIds]) - const filterThreadsByTopic = useCallback(() => { - // First filter out deleted events - const nonDeletedThreads = allThreads.filter(thread => !isEventDeleted(thread)) - - const categorizedThreads = nonDeletedThreads.map(thread => { - // Use new function to get categorized topic (considers both hashtags and t-tags) - const matchedTopic = getCategorizedTopic(thread, availableTopicIds) - - // Get all topics (hashtags + t-tags) for this thread - const allTopics = extractAllTopics(thread) - - // Check if this is a reading group thread (special subtopic for literature) - const isReadingGroup = allTopics.includes('readings') + // Fetch events on component mount and periodically + useEffect(() => { + if (allRelays.length > 0) { + fetchAllEvents() - return { - ...thread, - _categorizedTopic: matchedTopic, - _isReadingGroup: isReadingGroup, - _allTopics: allTopics - } - }) - - // 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, _allTopics, ...cleanThread } = thread - return cleanThread - }) - : categorizedThreads - .filter(thread => { - if (thread._categorizedTopic !== selectedTopic) return false - - // Handle subtopic filtering - if (selectedSubtopic) { - // Check if thread matches the selected subtopic - return thread._allTopics.includes(selectedSubtopic) - } - - return true - }) - .map(thread => { - // Remove the temporary categorization property but keep relay source - const { _categorizedTopic, _isReadingGroup, _allTopics, ...cleanThread } = thread - return cleanThread - }) + // Refetch every 5 minutes + const interval = setInterval(fetchAllEvents, 5 * 60 * 1000) + return () => clearInterval(interval) + } + }, [fetchAllEvents]) - // Apply global search filter - if (searchQuery.trim()) { - threadsForTopic = threadsForTopic.filter(thread => threadMatchesSearch(thread, searchQuery)) + // Filter events based on selected relay + const getFilteredEvents = useCallback(() => { + const events = Array.from(eventMap.values()) + + // Filter by selected relay if specified + let filtered = events + if (selectedRelay) { + // Check if it's a relay set + const relaySet = relaySets.find(set => set.id === selectedRelay) + if (relaySet) { + filtered = events.filter(entry => + entry.relaySources.some(source => relaySet.relayUrls.includes(source)) + ) + } else { + // It's an individual relay + filtered = events.filter(entry => + entry.relaySources.includes(selectedRelay) + ) + } } + + return filtered.map(entry => entry.event) + }, [eventMap, selectedRelay, relaySets]) - // Apply sorting based on selectedSort + // Filter threads by topic and search + const filterAndSortEvents = useCallback(() => { + const events = getFilteredEvents() - 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) + // Filter by topic + let filtered = events + if (selectedTopic !== 'all') { + filtered = events.filter(event => { + const entry = eventMap.get(event.id) + if (!entry) return false - // Replace the original array - threadsForTopic.length = 0 - threadsForTopic.push(...sortedOldest) + if (entry.categorizedTopic !== selectedTopic) return false - 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) + if (selectedSubtopic) { + return entry.allTopics.includes(selectedSubtopic) + } - 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 - }) + return true + }) + } + + // Apply search filter + if (searchQuery.trim()) { + const lowerQuery = searchQuery.toLowerCase().trim() + filtered = filtered.filter(event => { + // Search in title + const titleTag = event.tags.find(tag => tag[0] === 'title') + if (titleTag && titleTag[1]?.toLowerCase().includes(lowerQuery)) { + return true + } - // Replace the original array - threadsForTopic.length = 0 - threadsForTopic.push(...sortedControversial) + // Search in content + if (event.content.toLowerCase().includes(lowerQuery)) { + return true + } - 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 - }) + // Search in topics + const entry = eventMap.get(event.id) + if (entry) { + return entry.allTopics.some(topic => + topic.toLowerCase().includes(lowerQuery) + ) + } - // 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) + return false + }) } - - // If grouped view and showing all topics, group threads by topic + + // Sort events + const sorted = [...filtered].sort((a, b) => { + switch (selectedSort) { + case 'newest': + return b.created_at - a.created_at + case 'oldest': + return a.created_at - b.created_at + default: + return b.created_at - a.created_at + } + }) + + setFilteredEvents(sorted) + + // Handle grouped view if (viewMode === 'grouped' && selectedTopic === 'all') { - // Filter by search query first if present - const threadsToGroup = searchQuery.trim() - ? categorizedThreads.filter(thread => threadMatchesSearch(thread, searchQuery)) - : categorizedThreads - - // Group threads by topic - const groupedThreads = threadsToGroup.reduce((groups, thread) => { - const topic = thread._categorizedTopic + const grouped = sorted.reduce((groups, event) => { + const entry = eventMap.get(event.id) + if (!entry) return groups + + const topic = entry.categorizedTopic || 'general' if (!groups[topic]) { groups[topic] = [] } - // Remove the temporary categorization property but keep relay source - const { _categorizedTopic, _isReadingGroup, _allTopics, ...cleanThread } = thread - groups[topic].push(cleanThread) + groups[topic].push(event) 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 + + // Sort groups by newest event + const sortedGrouped = Object.fromEntries( + Object.entries(grouped) + .sort(([, eventsA], [, eventsB]) => { + const newestA = eventsA[0]?.created_at || 0 + const newestB = eventsB[0]?.created_at || 0 + return newestB - newestA }) ) + + setGroupedEvents(sortedGrouped) + } else { + setGroupedEvents({}) + } + }, [getFilteredEvents, selectedTopic, selectedSubtopic, selectedSort, searchQuery, viewMode, eventMap]) + + // Update filtered events when dependencies change + useEffect(() => { + filterAndSortEvents() + }, [filterAndSortEvents]) - // Store grouped data in a different state - setGroupedThreads(sortedGroupedThreads) - setThreads([]) // Clear flat threads + // Analyze topics whenever event map changes + useEffect(() => { + const events = Array.from(eventMap.values()).map(entry => entry.event) + if (events.length > 0) { + const analysis = analyzeThreadTopics(events, availableTopicIds) + setTopicAnalysis(analysis) } else { - // Flat view or specific topic selected - setThreads(threadsForTopic) - setGroupedThreads({}) // Clear grouped threads + setTopicAnalysis(new Map()) } - }, [ - allThreads, - availableTopicIds, - selectedTopic, - selectedSubtopic, - selectedSort, - viewMode, - searchQuery, - customVoteStats, - getThreadVoteScore, - getThreadControversyScore, - getThreadZapAmount, - isEventDeleted - ]) + }, [eventMap, availableTopicIds]) - // 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) + // Update available subtopics when topic analysis or selected topic changes + useEffect(() => { + if (selectedTopic && selectedTopic !== 'all') { + const subtopics = getDynamicSubtopics(topicAnalysis.get(selectedTopic), 3) + + // Special case: Always include 'readings' as a subtopic for 'literature' + if (selectedTopic === 'literature' && !subtopics.includes('readings')) { + subtopics.unshift('readings') + } + + setAvailableSubtopics(subtopics) + } else { + setAvailableSubtopics([]) } - }, [selectedSort, getThreadVoteScore, getThreadControversyScore]) + }, [topicAnalysis, selectedTopic]) const handleCreateThread = () => { setShowCreateThread(true) } - const handleThreadCreated = (publishedEvent?: NostrEvent) => { + const handleThreadCreated = () => { setShowCreateThread(false) - - if (publishedEvent) { - // Optimistically add the new thread to the local state - setAllThreads(prev => { - // Check if the thread is already in the list (avoid duplicates) - if (prev.some(thread => thread.id === publishedEvent.id)) { - return prev - } - - // Add relay source info - const eventHints = client.getEventHints(publishedEvent.id) - const relaySource = eventHints.length > 0 ? eventHints[0] : 'unknown' - const newThread = { ...publishedEvent, _relaySource: relaySource } - - // Add the new thread at the beginning (newest first) - return [newThread, ...prev] - }) - } else { - // Fallback: refresh all threads if no published event provided - fetchAllThreads() - } + // Refetch events to include the new thread + fetchAllEvents() } return ( @@ -617,12 +381,12 @@ const DiscussionsPage = forwardRef((_, ref) => { selectedTopic={selectedTopic} onTopicChange={(topic) => { setSelectedTopic(topic) - setSelectedSubtopic(null) // Reset subtopic when changing topic + setSelectedSubtopic(null) }} - threads={viewMode === 'grouped' && selectedTopic === 'all' ? allThreads : threads} + threads={viewMode === 'grouped' && selectedTopic === 'all' ? filteredEvents : filteredEvents} replies={[]} /> - {(availableRelays.length > 1 || relaySets.length > 0) && ( + {(allRelays.length > 1 || relaySets.length > 0) && ( - @@ -840,9 +606,9 @@ const DiscussionsPage = forwardRef((_, ref) => { ) : viewMode === 'grouped' && selectedTopic === 'all' ? (
- {Object.entries(groupedThreads).map(([topicId, topicThreads]) => { + {Object.entries(groupedEvents).map(([topicId, topicEvents]) => { const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topicId) - if (!topicInfo || topicThreads.length === 0) return null + if (!topicInfo || topicEvents.length === 0) return null return (
@@ -850,24 +616,23 @@ const DiscussionsPage = forwardRef((_, ref) => {

{topicInfo.label}

- ({topicThreads.length} {topicThreads.length === 1 ? t('thread') : t('threads')}) + ({topicEvents.length} {topicEvents.length === 1 ? t('thread') : t('threads')})
- {topicThreads.map(thread => { - // For grouped view, determine subtopics based on thread's categorized topic - const categorizedTopic = getCategorizedTopic(thread, availableTopicIds) - const threadSubtopics = categorizedTopic === 'literature' - ? ['readings'] // Always include readings for literature threads - : getDynamicSubtopics(topicAnalysis.get(categorizedTopic), 3) + {topicEvents.map(event => { + const entry = eventMap.get(event.id) + const threadSubtopics = entry?.categorizedTopic === 'literature' + ? ['readings'] + : getDynamicSubtopics(topicAnalysis.get(entry?.categorizedTopic || 'general'), 3) return ( { - push(toNote(thread)) + push(toNote(event)) }} /> ) @@ -879,21 +644,19 @@ const DiscussionsPage = forwardRef((_, ref) => {
) : (
- {threads.map(thread => { - // For "All Topics" view, determine subtopics based on thread's categorized topic - const categorizedTopic = getCategorizedTopic(thread, availableTopicIds) - const threadSubtopics = categorizedTopic === 'literature' - ? ['readings'] // Always include readings for literature threads - : getDynamicSubtopics(topicAnalysis.get(categorizedTopic), 3) - + {filteredEvents.map(event => { + const entry = eventMap.get(event.id) + const threadSubtopics = entry?.categorizedTopic === 'literature' + ? ['readings'] + : getDynamicSubtopics(topicAnalysis.get(entry?.categorizedTopic || 'general'), 3) return ( { - push(toNote(thread)) + push(toNote(event)) }} /> ) @@ -905,7 +668,7 @@ const DiscussionsPage = forwardRef((_, ref) => { {showCreateThread && ( setShowCreateThread(false)} @@ -918,4 +681,3 @@ const DiscussionsPage = forwardRef((_, ref) => { DiscussionsPage.displayName = 'DiscussionsPage' export default DiscussionsPage - diff --git a/src/providers/DeletedEventProvider.tsx b/src/providers/DeletedEventProvider.tsx index 04fc672..f17a9a5 100644 --- a/src/providers/DeletedEventProvider.tsx +++ b/src/providers/DeletedEventProvider.tsx @@ -1,9 +1,10 @@ import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' -import { NostrEvent, kinds } from 'nostr-tools' +import { NostrEvent } from 'nostr-tools' import { createContext, useCallback, useContext, useState } from 'react' type TDeletedEventContext = { addDeletedEvent: (event: NostrEvent) => void + addDeletedEventId: (eventId: string) => void isEventDeleted: (event: NostrEvent) => boolean } @@ -31,8 +32,12 @@ export function DeletedEventProvider({ children }: { children: React.ReactNode } setDeletedEventKeys((prev) => new Set(prev).add(getKey(event))) } + const addDeletedEventId = (eventId: string) => { + setDeletedEventKeys((prev) => new Set(prev).add(eventId)) + } + return ( - + {children} ) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 21d14dc..f9efe60 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -38,7 +38,6 @@ import * as nip49 from 'nostr-tools/nip49' import { createContext, useContext, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { useDeletedEvent } from '../DeletedEventProvider' import { BunkerSigner } from './bunker.signer' import { Nip07Signer } from './nip-07.signer' import { NostrConnectionSigner } from './nostrConnection.signer' @@ -107,7 +106,7 @@ export const useNostr = () => { export function NostrProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation() - const { addDeletedEvent } = useDeletedEvent() + // Note: Deletion event handling moved to individual components const [accounts, setAccounts] = useState( storage.getAccounts().map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })) ) @@ -278,41 +277,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } setRelayList(relayList) - // Fetch deletion events to populate the deleted event keys - try { - const relays = relayList.read?.slice(0, 5) || [] - if (relays.length > 0) { - // Fetch kind 5 (deletion) events from the last 30 days - const thirtyDaysAgo = Math.floor((Date.now() - (30 * 24 * 60 * 60 * 1000)) / 1000) - - const deletionEvents = await client.fetchEvents(relays, { - kinds: [kinds.EventDeletion], - since: thirtyDaysAgo, - limit: 1000 - }) - - // Process deletion events to extract deleted event IDs - const newDeletedKeys = new Set() - - for (const deletionEvent of deletionEvents) { - // Kind 5 events contain 'e' tags with the IDs of deleted events - const deletedEventTags = deletionEvent.tags.filter(tag => tag[0] === 'e' && tag[1]) - - for (const tag of deletedEventTags) { - const deletedEventId = tag[1] - if (deletedEventId) { - newDeletedKeys.add(deletedEventId) - // Also add to the DeletedEventProvider - addDeletedEvent({ id: deletedEventId } as Event) - } - } - } - - console.log(`Fetched ${deletionEvents.length} deletion events, found ${newDeletedKeys.size} deleted event IDs`) - } - } catch (error) { - console.warn('Failed to fetch deletion events:', error) - } + // Note: Deletion event fetching is now handled locally by individual components + // for better performance and accuracy const normalizedRelays = [ ...relayList.write.map(url => normalizeUrl(url) || url), @@ -804,7 +770,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const result = await client.publishEvent(relays, deletionRequest) - addDeletedEvent(targetEvent) + // Note: We don't need to add the deleted event to the provider here + // since it's being published as a kind 5 event and will be fetched later // Show publishing feedback if (result.relayStatuses) {