import { forwardRef, useEffect, useState, useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { RefreshCw, Search } from 'lucide-react' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useSmartNoteNavigation } from '@/PageManager' import { toNote } from '@/lib/link' import logger from '@/lib/logger' import { NostrEvent, Event as NostrEventType } from 'nostr-tools' import { kinds } from 'nostr-tools' import { normalizeUrl } from '@/lib/url' import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import client from '@/services/client.service' import { DISCUSSION_TOPICS } from './CreateThreadDialog' import ThreadCard from './ThreadCard' import CreateThreadDialog from './CreateThreadDialog' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { extractGroupInfo } from '@/lib/discussion-topics' // Simple event map type type EventMapEntry = { event: NostrEvent relaySources: string[] tTags: string[] hashtags: string[] allTopics: string[] categorizedTopic: string commentCount: number lastCommentTime: number lastVoteTime: number upVotes: number downVotes: number // Group-related fields groupId: string | null groupRelay: string | null groupDisplayName: string | null isGroupDiscussion: boolean } // Vote counting function - separate and clean function countVotesForThread(threadId: string, reactions: NostrEvent[], threadAuthor: string): { upVotes: number, downVotes: number, lastVoteTime: number } { const userVotes = new Map() let lastVoteTime = 0 // Normalize reaction content according to NIP-25 const normalizeReaction = (content: string): string => { const normalized = content.trim() if (normalized === '' || normalized === '+') return '+' if (normalized === '-') return '-' if (normalized === '⬆️' || normalized === '↑' || normalized === '👍' || normalized === '❤️' || normalized === '🔥') return '+' if (normalized === '⬇️' || normalized === '↓' || normalized === '👎' || normalized === '💩') return '-' return 'emoji' } logger.debug('[DiscussionsPage] Counting votes for thread', threadId.substring(0, 8), 'with', reactions.length, 'reactions') // Process all reactions for this thread reactions.forEach(reaction => { const eTags = reaction.tags.filter(tag => tag[0] === 'e' && tag[1]) eTags.forEach(tag => { if (tag[1] === threadId) { logger.debug('[DiscussionsPage] Found reaction for thread', threadId.substring(0, 8), ':', { content: reaction.content, pubkey: reaction.pubkey.substring(0, 8), isSelf: reaction.pubkey === threadAuthor, created_at: reaction.created_at }) // Skip self-votes if (reaction.pubkey === threadAuthor) { logger.debug('[DiscussionsPage] Skipping self-vote') return } const normalizedReaction = normalizeReaction(reaction.content) logger.debug('[DiscussionsPage] Normalized reaction:', normalizedReaction) if (normalizedReaction === '+' || normalizedReaction === '-') { const existingVote = userVotes.get(reaction.pubkey) // Only keep the newest vote from each user if (!existingVote || reaction.created_at > existingVote.created_at) { userVotes.set(reaction.pubkey, { type: normalizedReaction, created_at: reaction.created_at }) logger.debug('[DiscussionsPage] Added vote:', normalizedReaction, 'from', reaction.pubkey.substring(0, 8)) } } } }) }) // Count votes let upVotes = 0 let downVotes = 0 userVotes.forEach(({ type, created_at }) => { if (type === '+') upVotes++ else if (type === '-') downVotes++ if (created_at > lastVoteTime) lastVoteTime = created_at }) return { upVotes, downVotes, lastVoteTime } } // Comment counting function - separate and clean function countCommentsForThread(threadId: string, comments: NostrEvent[], threadAuthor: string): { commentCount: number, lastCommentTime: number } { let commentCount = 0 let lastCommentTime = 0 comments.forEach(comment => { const eTags = comment.tags.filter(tag => tag[0] === 'e' && tag[1]) eTags.forEach(tag => { if (tag[1] === threadId) { // Skip self-comments if (comment.pubkey === threadAuthor) return commentCount++ if (comment.created_at > lastCommentTime) { lastCommentTime = comment.created_at } } }) }) return { commentCount, lastCommentTime } } // Topic categorization function function getTopicFromTags(allTopics: string[], predefinedTopicIds: string[], isGroupDiscussion: boolean = false): string { // If it's a group discussion, categorize as 'groups' if (isGroupDiscussion) { return 'groups' } for (const topic of allTopics) { if (predefinedTopicIds.includes(topic)) { return topic } } return 'general' } // Normalize topic function function normalizeTopic(topic: string): string { return topic.toLowerCase().replace(/\s+/g, '-') } // Search function for threads async function searchThreads(entries: EventMapEntry[], query: string): Promise { if (!query.trim()) return entries const searchTerm = query.toLowerCase().trim() // Search for profiles that match the query const matchingPubkeys = new Set() try { const profiles = await client.searchProfilesFromLocal(searchTerm, 50) profiles.forEach(profile => { matchingPubkeys.add(profile.pubkey) }) } catch (error) { logger.debug('[DiscussionsPage] Profile search failed:', error) } return entries.filter(entry => { const thread = entry.event // Search in title (from tags) const titleTag = thread.tags.find(tag => tag[0] === 'title') const title = titleTag ? titleTag[1].toLowerCase() : '' // Search in content const content = thread.content.toLowerCase() // Search in tags (t-tags and hashtags) const allTags = [...entry.tTags, ...entry.hashtags].join(' ').toLowerCase() // Search in full author npub const authorNpub = thread.pubkey.toLowerCase() // Search in author tag (for readings) const authorTag = thread.tags.find(tag => tag[0] === 'author') const author = authorTag ? authorTag[1].toLowerCase() : '' // Search in subject tag (for readings) const subjectTag = thread.tags.find(tag => tag[0] === 'subject') const subject = subjectTag ? subjectTag[1].toLowerCase() : '' // Check if author matches profile search const authorMatchesProfile = matchingPubkeys.has(thread.pubkey) return title.includes(searchTerm) || content.includes(searchTerm) || allTags.includes(searchTerm) || authorNpub.includes(searchTerm) || author.includes(searchTerm) || subject.includes(searchTerm) || authorMatchesProfile }) } // Dynamic topic analysis interface DynamicTopic { id: string label: string count: number isMainTopic: boolean isSubtopic: boolean parentTopic?: string } function analyzeDynamicTopics(entries: EventMapEntry[]): { mainTopics: DynamicTopic[] subtopics: DynamicTopic[] allTopics: DynamicTopic[] } { const hashtagCounts = new Map() const groupCounts = new Map() const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id) // Count hashtag frequency entries.forEach(entry => { const allTopics = [...entry.tTags, ...entry.hashtags] allTopics.forEach(topic => { if (topic && topic !== 'general' && !predefinedTopicIds.includes(topic)) { hashtagCounts.set(topic, (hashtagCounts.get(topic) || 0) + 1) } }) // Count group discussions if (entry.isGroupDiscussion && entry.groupDisplayName) { groupCounts.set(entry.groupDisplayName, (groupCounts.get(entry.groupDisplayName) || 0) + 1) } }) const mainTopics: DynamicTopic[] = [] const subtopics: DynamicTopic[] = [] // Create dynamic topics based on frequency hashtagCounts.forEach((count, hashtag) => { const topic: DynamicTopic = { id: hashtag, label: hashtag.charAt(0).toUpperCase() + hashtag.slice(1).replace(/-/g, ' '), count, isMainTopic: count >= 10, isSubtopic: count >= 3 && count < 10 } if (topic.isMainTopic) { mainTopics.push(topic) } else if (topic.isSubtopic) { subtopics.push(topic) } }) // Add "Groups" as a pseudo main-topic if we have group discussions if (groupCounts.size > 0) { const totalGroupDiscussions = Array.from(groupCounts.values()).reduce((sum, count) => sum + count, 0) const groupsMainTopic: DynamicTopic = { id: 'groups', label: 'Groups', count: totalGroupDiscussions, isMainTopic: true, isSubtopic: false } mainTopics.push(groupsMainTopic) // Add individual groups as subtopics under "Groups" groupCounts.forEach((count, groupDisplayName) => { const groupSubtopic: DynamicTopic = { id: `groups-${groupDisplayName}`, label: groupDisplayName, count, isMainTopic: false, isSubtopic: true, parentTopic: 'groups' } subtopics.push(groupSubtopic) }) } // Sort by count (most popular first) mainTopics.sort((a, b) => b.count - a.count) subtopics.sort((a, b) => b.count - a.count) const allTopics = [...mainTopics, ...subtopics] // Debug logging (commented out to reduce console spam) // console.log('Dynamic topics analysis:', { // hashtagCounts: Object.fromEntries(hashtagCounts), // mainTopics: mainTopics.map(t => ({ id: t.id, count: t.count })), // subtopics: subtopics.map(t => ({ id: t.id, count: t.count })), // allTopics: allTopics.map(t => ({ id: t.id, count: t.count, isMainTopic: t.isMainTopic, isSubtopic: t.isSubtopic })) // }) return { mainTopics, subtopics, allTopics } } // Enhanced topic categorization with dynamic topics function getEnhancedTopicFromTags(allTopics: string[], predefinedTopicIds: string[], dynamicTopics: DynamicTopic[], isGroupDiscussion: boolean = false): string { // If it's a group discussion, categorize as 'groups' if (isGroupDiscussion) { return 'groups' } // First check predefined topics (these are main topics) for (const topic of allTopics) { if (predefinedTopicIds.includes(topic)) { return topic } } // Then check dynamic main topics for (const topic of allTopics) { const dynamicTopic = dynamicTopics.find(dt => dt.id === topic && dt.isMainTopic) if (dynamicTopic) { return topic } } // If no main topic found, return 'general' as the main topic // The grouping logic will handle subtopics under their main topics return 'general' } function DiscussionsPageTitlebar() { const { t } = useTranslation() return (

{t('Discussions')}

) } const DiscussionsPage = forwardRef((_, ref) => { const { t } = useTranslation() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { pubkey } = useNostr() const { navigateToNote } = useSmartNoteNavigation() // State const [allEventMap, setAllEventMap] = useState>(new Map()) const [eventMap, setEventMap] = useState>(new Map()) const [timeSpan, setTimeSpan] = useState<'30days' | '90days' | 'all'>('30days') const [timeSpanCounts, setTimeSpanCounts] = useState<{ '30days': number, '90days': number, 'all': number }>({ '30days': 0, '90days': 0, 'all': 0 }) const [loading, setLoading] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const [showCreateDialog, setShowCreateDialog] = useState(false) const [selectedTopic, setSelectedTopic] = useState('all') const [searchQuery, setSearchQuery] = useState('') const [dynamicTopics, setDynamicTopics] = useState<{ mainTopics: DynamicTopic[] subtopics: DynamicTopic[] allTopics: DynamicTopic[] }>({ mainTopics: [], subtopics: [], allTopics: [] }) // Build comprehensive relay list (same as pins) const buildComprehensiveRelayList = useCallback(async () => { const myRelayList = pubkey ? await client.fetchRelayList(pubkey) : { write: [], read: [] } const allRelays = [ ...(myRelayList.read || []), // User's inboxes (kind 10002) ...(myRelayList.write || []), // User's outboxes (kind 10002) ...(favoriteRelays || []), // User's favorite relays (kind 10012) ...BIG_RELAY_URLS, // Big relays ...FAST_READ_RELAY_URLS, // Fast read relays ...FAST_WRITE_RELAY_URLS // Fast write relays ] // Normalize and deduplicate relay URLs const normalizedRelays = allRelays .map(url => normalizeUrl(url)) .filter((url): url is string => !!url) // Filter blocked relays const finalRelays = normalizedRelays.filter(relay => !blockedRelays.some(blocked => relay.includes(blocked)) ) logger.debug('[DiscussionsPage] Using', finalRelays.length, 'comprehensive relays') return Array.from(new Set(finalRelays)) }, []) // No dependencies - will be called fresh each time from fetchAllEvents // Fetch all events const fetchAllEvents = useCallback(async () => { if (loading) return setLoading(true) setIsRefreshing(true) try { logger.debug('[DiscussionsPage] Fetching all discussion threads...') // Get comprehensive relay list const allRelays = await buildComprehensiveRelayList() logger.debug('[DiscussionsPage] Using relays:', allRelays.slice(0, 10), '... (total:', allRelays.length, ')') // Step 1: Fetch all discussion threads (kind 11) const discussionThreads = await client.fetchEvents(allRelays, [ { kinds: [11], // ExtendedKind.DISCUSSION limit: 100 } ]) logger.debug('[DiscussionsPage] Fetched', discussionThreads.length, 'discussion threads') if (discussionThreads.length > 0) { logger.debug('[DiscussionsPage] Sample threads:', discussionThreads.slice(0, 3).map(t => ({ id: t.id.substring(0, 8), pubkey: t.pubkey.substring(0, 8), created_at: new Date(t.created_at * 1000).toISOString() }))) } // Step 2: Get thread IDs and fetch related comments and reactions const threadIds = discussionThreads.map((thread: NostrEvent) => thread.id) const [comments, reactions] = await Promise.all([ threadIds.length > 0 ? client.fetchEvents(allRelays, [ { kinds: [1111], // ExtendedKind.COMMENT '#e': threadIds, limit: 100 } ]) : Promise.resolve([]), threadIds.length > 0 ? client.fetchEvents(allRelays, [ { kinds: [kinds.Reaction], '#e': threadIds, limit: 100 } ]) : Promise.resolve([]) ]) logger.debug('[DiscussionsPage] Fetched', comments.length, 'comments and', reactions.length, 'reactions') // Debug: Log some reaction details if (reactions.length > 0) { logger.debug('[DiscussionsPage] Sample reactions:', reactions.slice(0, 3).map(r => ({ id: r.id.substring(0, 8), content: r.content, pubkey: r.pubkey.substring(0, 8), tags: r.tags.filter(t => t[0] === 'e') }))) } // Step 3: Build event map with vote and comment counts const newEventMap = new Map() discussionThreads.forEach((thread: NostrEvent) => { const threadId = thread.id const threadAuthor = thread.pubkey // Count votes and comments const voteStats = countVotesForThread(threadId, reactions, threadAuthor) const commentStats = countCommentsForThread(threadId, comments, threadAuthor) // Debug: Log vote stats for threads with votes if (voteStats.upVotes > 0 || voteStats.downVotes > 0) { logger.debug('[DiscussionsPage] Thread', threadId.substring(0, 8), 'has votes:', voteStats) } // Get relay sources const eventHints = client.getEventHints(threadId) const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] // Extract group information const groupInfo = extractGroupInfo(thread, relaySources) // Extract topics const tTagsRaw = thread.tags.filter((tag: string[]) => tag[0] === 't' && tag[1]).map((tag: string[]) => tag[1].toLowerCase()) const hashtagsRaw = (thread.content.match(/#\w+/g) || []).map((tag: string) => tag.slice(1).toLowerCase()) const allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])] // Categorize topic (will be updated after dynamic topics are analyzed) const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds, groupInfo.isGroupDiscussion) // Normalize topics const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag)) const hashtags = hashtagsRaw.map((tag: string) => normalizeTopic(tag)) const allTopics = [...new Set([...tTags, ...hashtags])] newEventMap.set(threadId, { event: thread, relaySources, tTags, hashtags, allTopics, categorizedTopic, commentCount: commentStats.commentCount, lastCommentTime: commentStats.lastCommentTime, lastVoteTime: voteStats.lastVoteTime, upVotes: voteStats.upVotes, downVotes: voteStats.downVotes, // Group-related fields groupId: groupInfo.groupId, groupRelay: groupInfo.groupRelay, groupDisplayName: groupInfo.groupDisplayName, isGroupDiscussion: groupInfo.isGroupDiscussion }) }) logger.debug('[DiscussionsPage] Built event map with', newEventMap.size, 'threads') // Log vote counts for debugging newEventMap.forEach((entry, threadId) => { if (entry.upVotes > 0 || entry.downVotes > 0) { logger.debug('[DiscussionsPage] Thread', threadId.substring(0, 8) + '...', 'has', entry.upVotes, 'upvotes,', entry.downVotes, 'downvotes') } }) // Analyze dynamic topics only if we have new data let dynamicTopicsAnalysis: { mainTopics: DynamicTopic[]; subtopics: DynamicTopic[]; allTopics: DynamicTopic[] } = { mainTopics: [], subtopics: [], allTopics: [] } if (newEventMap.size > 0) { dynamicTopicsAnalysis = analyzeDynamicTopics(Array.from(newEventMap.values())) setDynamicTopics(dynamicTopicsAnalysis) } // Update event map with enhanced topic categorization const updatedEventMap = new Map() newEventMap.forEach((entry, threadId) => { const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) const enhancedTopic = getEnhancedTopicFromTags(entry.allTopics, predefinedTopicIds, dynamicTopicsAnalysis.allTopics, entry.isGroupDiscussion) updatedEventMap.set(threadId, { ...entry, categorizedTopic: enhancedTopic }) }) setAllEventMap(updatedEventMap) } catch (error) { logger.error('[DiscussionsPage] Error fetching events:', error) } finally { setLoading(false) setIsRefreshing(false) } }, []) // Only run when explicitly called (mount or refresh button) // Calculate time span counts const calculateTimeSpanCounts = useCallback(() => { const now = Date.now() const thirtyDaysAgo = now - (30 * 24 * 60 * 60 * 1000) const ninetyDaysAgo = now - (90 * 24 * 60 * 60 * 1000) let count30 = 0 let count90 = 0 let countAll = 0 allEventMap.forEach((entry) => { const threadTime = entry.event.created_at * 1000 const lastCommentTime = entry.lastCommentTime > 0 ? entry.lastCommentTime * 1000 : 0 const lastVoteTime = entry.lastVoteTime > 0 ? entry.lastVoteTime * 1000 : 0 // For threads without comments/votes, only use thread creation time const mostRecentActivity = Math.max( threadTime, lastCommentTime, lastVoteTime ) if (mostRecentActivity > thirtyDaysAgo) count30++ if (mostRecentActivity > ninetyDaysAgo) count90++ countAll++ }) setTimeSpanCounts({ '30days': count30, '90days': count90, 'all': countAll }) }, [allEventMap]) // Filter event map for display const filterEventMapForDisplay = useCallback(() => { const now = Date.now() const timeSpanAgo = timeSpan === '30days' ? now - (30 * 24 * 60 * 60 * 1000) : timeSpan === '90days' ? now - (90 * 24 * 60 * 60 * 1000) : 0 const filteredMap = new Map() allEventMap.forEach((entry) => { // Filter by time span let passesTimeFilter = false if (timeSpan === 'all') { passesTimeFilter = true } else { const threadTime = entry.event.created_at * 1000 const lastCommentTime = entry.lastCommentTime > 0 ? entry.lastCommentTime * 1000 : 0 const lastVoteTime = entry.lastVoteTime > 0 ? entry.lastVoteTime * 1000 : 0 const mostRecentActivity = Math.max( threadTime, lastCommentTime, lastVoteTime ) passesTimeFilter = mostRecentActivity > timeSpanAgo } // Filter by topic (including group filtering) let passesTopicFilter = false if (selectedTopic === 'all') { passesTopicFilter = true } else if (selectedTopic === 'groups') { // Show all group discussions when "Groups" main topic is selected passesTopicFilter = entry.isGroupDiscussion } else if (selectedTopic.startsWith('groups-')) { // Show specific group when group subtopic is selected const groupDisplayName = selectedTopic.replace('groups-', '') passesTopicFilter = entry.isGroupDiscussion && entry.groupDisplayName === groupDisplayName } else { // Regular topic filtering passesTopicFilter = entry.categorizedTopic === selectedTopic } if (passesTimeFilter && passesTopicFilter) { filteredMap.set(entry.event.id, entry) } }) setEventMap(filteredMap) }, [allEventMap, timeSpan, selectedTopic, searchQuery]) // Effects useEffect(() => { fetchAllEvents() }, []) // Only run once on mount useEffect(() => { if (allEventMap.size > 0) { calculateTimeSpanCounts() } }, [allEventMap]) // Run when allEventMap changes useEffect(() => { if (allEventMap.size > 0) { filterEventMapForDisplay() } }, [allEventMap, timeSpan, selectedTopic]) // Run when allEventMap, timeSpan, or selectedTopic changes // Get available topics sorted by most recent activity (including dynamic topics) const availableTopics = useMemo(() => { const topicMap = new Map() allEventMap.forEach((entry) => { const topic = entry.categorizedTopic const lastActivity = Math.max( entry.event.created_at * 1000, entry.lastCommentTime > 0 ? entry.lastCommentTime * 1000 : 0, entry.lastVoteTime > 0 ? entry.lastVoteTime * 1000 : 0 ) if (!topicMap.has(topic)) { const dynamicTopic = dynamicTopics.allTopics.find(dt => dt.id === topic) topicMap.set(topic, { count: 0, lastActivity: 0, isDynamic: !!dynamicTopic, isMainTopic: dynamicTopic?.isMainTopic || false, isSubtopic: dynamicTopic?.isSubtopic || false }) } const current = topicMap.get(topic)! current.count++ current.lastActivity = Math.max(current.lastActivity, lastActivity) }) // Convert to array and sort by most recent activity return Array.from(topicMap.entries()) .map(([topic, data]) => ({ topic, ...data })) .sort((a, b) => b.lastActivity - a.lastActivity) }, [allEventMap, dynamicTopics]) // State for search results const [searchedEntries, setSearchedEntries] = useState([]) const [isSearching, setIsSearching] = useState(false) // Handle search with debouncing useEffect(() => { const performSearch = async () => { if (!searchQuery.trim()) { setSearchedEntries(Array.from(eventMap.values())) return } setIsSearching(true) try { const allEntries = Array.from(eventMap.values()) const results = await searchThreads(allEntries, searchQuery) setSearchedEntries(results) } catch (error) { logger.error('[DiscussionsPage] Search failed:', error) setSearchedEntries(Array.from(eventMap.values())) } finally { setIsSearching(false) } } const timeoutId = setTimeout(performSearch, 300) // 300ms debounce return () => clearTimeout(timeoutId) }, [eventMap, searchQuery]) // Group events by topic with hierarchy (main topics and subtopics) const groupedEvents = useMemo(() => { const mainTopicGroups = new Map }>() searchedEntries.forEach((entry) => { // Check if this entry has any dynamic subtopics const entrySubtopics = entry.allTopics.filter(topic => { const dynamicTopic = dynamicTopics.allTopics.find(dt => dt.id === topic && dt.isSubtopic) return !!dynamicTopic }) if (entrySubtopics.length > 0) { // This entry has subtopics - group under the main topic with the subtopic const mainTopic = entry.categorizedTopic const subtopic = entrySubtopics[0] // Initialize main topic group if it doesn't exist if (!mainTopicGroups.has(mainTopic)) { mainTopicGroups.set(mainTopic, { entries: [], subtopics: new Map() }) } const group = mainTopicGroups.get(mainTopic)! // Add to subtopic group if (!group.subtopics.has(subtopic)) { group.subtopics.set(subtopic, []) } group.subtopics.get(subtopic)!.push(entry) } else { // No subtopic, add to main topic const mainTopic = entry.categorizedTopic // Initialize main topic group if it doesn't exist if (!mainTopicGroups.has(mainTopic)) { mainTopicGroups.set(mainTopic, { entries: [], subtopics: new Map() }) } const group = mainTopicGroups.get(mainTopic)! group.entries.push(entry) } }) // Sort threads within each group and subtopic by newest-first mainTopicGroups.forEach((group) => { const sortEntries = (entries: EventMapEntry[]) => { entries.sort((a, b) => { const aActivity = Math.max( a.event.created_at * 1000, a.lastCommentTime > 0 ? a.lastCommentTime * 1000 : 0, a.lastVoteTime > 0 ? a.lastVoteTime * 1000 : 0 ) const bActivity = Math.max( b.event.created_at * 1000, b.lastCommentTime > 0 ? b.lastCommentTime * 1000 : 0, b.lastVoteTime > 0 ? b.lastVoteTime * 1000 : 0 ) return bActivity - aActivity // Newest first }) } sortEntries(group.entries) group.subtopics.forEach((entries) => sortEntries(entries)) }) // Sort groups by most recent activity (newest first) const sortedGroups = new Map }>() const sortedEntries = Array.from(mainTopicGroups.entries()).sort(([, aGroup], [, bGroup]) => { const aEntries = aGroup.entries const bEntries = bGroup.entries if (aEntries.length === 0 && bEntries.length === 0) return 0 if (aEntries.length === 0) return 1 if (bEntries.length === 0) return -1 const aMostRecent = Math.max( aEntries[0].event.created_at * 1000, aEntries[0].lastCommentTime > 0 ? aEntries[0].lastCommentTime * 1000 : 0, aEntries[0].lastVoteTime > 0 ? aEntries[0].lastVoteTime * 1000 : 0 ) const bMostRecent = Math.max( bEntries[0].event.created_at * 1000, bEntries[0].lastCommentTime > 0 ? bEntries[0].lastCommentTime * 1000 : 0, bEntries[0].lastVoteTime > 0 ? bEntries[0].lastVoteTime * 1000 : 0 ) return bMostRecent - aMostRecent // Newest first }) sortedEntries.forEach(([topic, group]) => { sortedGroups.set(topic, group) }) return sortedGroups }, [searchedEntries, dynamicTopics]) // Handle refresh const handleRefresh = () => { fetchAllEvents() } // Handle create thread const handleCreateThread = (publishedEvent?: NostrEventType) => { if (!publishedEvent) return // Add to event map immediately const threadId = publishedEvent.id const tTagsRaw = publishedEvent.tags.filter((tag: string[]) => tag[0] === 't' && tag[1]).map((tag: string[]) => tag[1].toLowerCase()) const hashtagsRaw = (publishedEvent.content.match(/#\w+/g) || []).map((tag: string) => tag.slice(1).toLowerCase()) const allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])] const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag)) const hashtags = hashtagsRaw.map((tag: string) => normalizeTopic(tag)) const allTopics = [...new Set([...tTags, ...hashtags])] const eventHints = client.getEventHints(threadId) const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] // Extract group information const groupInfo = extractGroupInfo(publishedEvent, relaySources) const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds, groupInfo.isGroupDiscussion) const newEntry: EventMapEntry = { event: publishedEvent, relaySources, tTags, hashtags, allTopics, categorizedTopic, commentCount: 0, lastCommentTime: 0, lastVoteTime: 0, upVotes: 0, downVotes: 0, // Group-related fields groupId: groupInfo.groupId, groupRelay: groupInfo.groupRelay, groupDisplayName: groupInfo.groupDisplayName, isGroupDiscussion: groupInfo.isGroupDiscussion } setAllEventMap(prev => new Map(prev).set(threadId, newEntry)) // Close the dialog setShowCreateDialog(false) } // Handle close dialog const handleCloseDialog = () => { setShowCreateDialog(false) } // Handle thread click const handleThreadClick = (threadId: string) => { navigateToNote(toNote(threadId)) } return ( } displayScrollToTopButton >
{/* Search Bar */}
{isSearching ? ( ) : ( )} setSearchQuery(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
{/* Filters - Stack on mobile, row on desktop */}
{/* Topic Selection Dropdown */} {/* Time Span Dropdown */} {/* Refresh Button */}
{/* Content */}
{loading ? (
{t('Loading...')}
) : isSearching ? (
{t('Searching...')}
) : (
{Array.from(groupedEvents.entries()).map(([mainTopic, group]) => { const topicInfo = availableTopics.find(t => t.topic === mainTopic) const isDynamicMain = topicInfo?.isDynamic && topicInfo?.isMainTopic return (
{/* Main Topic Header */}

{isDynamicMain && 🔥} {mainTopic} ({group.entries.length + Array.from(group.subtopics.values()).reduce((sum, events) => sum + events.length, 0)} {group.entries.length + Array.from(group.subtopics.values()).reduce((sum, events) => sum + events.length, 0) === 1 ? t('thread') : t('threads')}) {isDynamicMain && Main Topic}

{/* Main Topic Threads */} {group.entries.length > 0 && (
{group.entries.map((entry) => ( handleThreadClick(entry.event.id)} /> ))}
)} {/* Subtopic Groups */} {group.subtopics.size > 0 && (
{Array.from(group.subtopics.entries()).map(([subtopic, subtopicEvents]) => { const subtopicInfo = availableTopics.find(t => t.topic === subtopic) const isSubtopicDynamic = subtopicInfo?.isDynamic && subtopicInfo?.isSubtopic return (

{isSubtopicDynamic && 📌} {subtopic} ({subtopicEvents.length} {subtopicEvents.length === 1 ? t('thread') : t('threads')}) {isSubtopicDynamic && Subtopic}

{subtopicEvents.map((entry) => ( handleThreadClick(entry.event.id)} /> ))}
) })}
)}
) })}
)}
{/* Create Thread Dialog */} {showCreateDialog && ( )}
) }) DiscussionsPage.displayName = 'DiscussionsPage' export default DiscussionsPage