From 29bfc72a63618b39934be0beaf422f648e868047 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 26 Oct 2025 11:34:23 +0100 Subject: [PATCH] fix counters and voting. refactor discussions --- .../primary/DiscussionsPage/ThreadCard.tsx | 173 +- src/pages/primary/DiscussionsPage/index.tsx | 1560 +++++------------ 2 files changed, 487 insertions(+), 1246 deletions(-) diff --git a/src/pages/primary/DiscussionsPage/ThreadCard.tsx b/src/pages/primary/DiscussionsPage/ThreadCard.tsx index 5c549c4..6f36052 100644 --- a/src/pages/primary/DiscussionsPage/ThreadCard.tsx +++ b/src/pages/primary/DiscussionsPage/ThreadCard.tsx @@ -1,6 +1,6 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { Clock, Hash, Server } from 'lucide-react' +import { Clock, Hash } from 'lucide-react' import { NostrEvent } from 'nostr-tools' import { formatDistanceToNow } from 'date-fns' import { useTranslation } from 'react-i18next' @@ -12,22 +12,27 @@ import VoteButtons from '@/components/NoteStats/VoteButtons' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { extractAllTopics } from '@/lib/discussion-topics' -interface ThreadWithRelaySource extends NostrEvent { - _relaySource?: string -} - interface ThreadCardProps { - thread: ThreadWithRelaySource + thread: NostrEvent onThreadClick: () => void className?: string - subtopics?: string[] // Available subtopics for this thread - primaryTopic?: string // The categorized primary topic (e.g., 'general', 'tech', etc.) commentCount?: number lastCommentTime?: number lastVoteTime?: number + upVotes?: number + downVotes?: number } -export default function ThreadCard({ thread, onThreadClick, className, subtopics = [], primaryTopic, commentCount = 0, lastCommentTime = 0, lastVoteTime = 0 }: ThreadCardProps) { +export default function ThreadCard({ + thread, + onThreadClick, + className, + commentCount = 0, + lastCommentTime = 0, + lastVoteTime = 0, + upVotes = 0, + downVotes = 0 +}: ThreadCardProps) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() @@ -35,25 +40,17 @@ export default function ThreadCard({ thread, onThreadClick, className, subtopics const titleTag = thread.tags.find(tag => tag[0] === 'title' && tag[1]) const title = titleTag?.[1] || t('Untitled') - // Use the categorized primary topic if provided, otherwise extract from tags - const topic = primaryTopic || (() => { - const topicTag = thread.tags.find(tag => tag[0] === 't' && tag[1]) - const firstTag = topicTag?.[1] || 'general' - - // If the first tag is not a predefined topic, default to 'general' - const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id) - return predefinedTopicIds.includes(firstTag) ? firstTag : 'general' - })() - - // Extract author and subject for readings threads - const authorTag = thread.tags.find(tag => tag[0] === 'author' && tag[1]) - const subjectTag = thread.tags.find(tag => tag[0] === 'subject' && tag[1]) - const isReadingGroup = thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings') + // Get topic info + const topicTag = thread.tags.find(tag => tag[0] === 't' && tag[1]) + const topic = topicTag?.[1] || 'general' + const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topic) || { + id: topic, + label: topic, + icon: Hash + } - // Get first 250 characters of content - const contentPreview = thread.content.length > 250 - ? thread.content.substring(0, 250) + '...' - : thread.content + // Get all topics from this thread + const allTopics = extractAllTopics(thread) // Format creation time const createdAt = new Date(thread.created_at * 1000) @@ -67,47 +64,16 @@ export default function ThreadCard({ thread, onThreadClick, className, subtopics const lastCommentAgo = formatLastActivity(lastCommentTime) const lastVoteAgo = formatLastActivity(lastVoteTime) - - // Get topic display info from centralized DISCUSSION_TOPICS - const getTopicInfo = (topicId: string) => { - const topic = DISCUSSION_TOPICS.find(t => t.id === topicId) - return topic || { - id: topicId, - label: topicId, - icon: Hash - } - } - - const topicInfo = getTopicInfo(topic) - - // Get all topics from this thread - const allTopics = extractAllTopics(thread) - // Find which subtopics this thread matches - // Handle both normalized and original forms (e.g., 'readings' -> 'reading') - const matchingSubtopics = subtopics.filter(subtopic => { - // Direct match - if (allTopics.includes(subtopic)) return true - - // Check if any topic in allTopics matches when we normalize the subtopic - // This handles cases like 'readings' in subtopics matching 'reading' in allTopics - const normalizedSubtopic = subtopic.replace(/s$/, '') // Remove trailing 's' - if (allTopics.includes(normalizedSubtopic)) return true - - return false - }) + // Calculate vote counts + const totalVotes = upVotes + downVotes + const netVotes = upVotes - downVotes + // Get content preview + const contentPreview = thread.content.length > 250 + ? thread.content.substring(0, 250) + '...' + : thread.content - // Format relay name for display - const formatRelayName = (relaySource: string) => { - if (relaySource === 'multiple') { - return t('Multiple Relays') - } - if (relaySource === 'unknown') { - return t('Unknown Relay') - } - return relaySource.replace('wss://', '').replace('ws://', '') - } return (
- +
+
+{upVotes}
+
-{downVotes}
+

{title} @@ -131,23 +100,13 @@ export default function ThreadCard({ thread, onThreadClick, className, subtopics {topicInfo.id}

- {matchingSubtopics.map(subtopic => ( - + {allTopics.slice(0, 3).map(topic => ( + - {subtopic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} + {topic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} ))}
- {isReadingGroup && (authorTag || subjectTag) && ( -
- {authorTag && ( - Author: {authorTag[1]} - )} - {subjectTag && ( - Book: {subjectTag[1]} - )} -
- )}
@@ -158,21 +117,29 @@ export default function ThreadCard({ thread, onThreadClick, className, subtopics skeletonClassName="h-4 w-20" />
- {thread._relaySource && ( - - - {formatRelayName(thread._relaySource)} - - )}
{timeAgo}
+ + {/* Vote counts */} + {totalVotes > 0 && ( +
+ 0 ? 'text-green-600' : netVotes < 0 ? 'text-red-600' : ''}> + {netVotes > 0 ? '+' : ''}{netVotes} + + {' '}{t('votes')} ({totalVotes} {t('total')}) +
+ )} + + {/* Comment count */} {commentCount > 0 && (
{commentCount} {commentCount === 1 ? t('comment') : t('comments')}
)} + + {/* Last activity */} {lastCommentAgo && (
{t('last commented')}: {lastCommentAgo} @@ -195,33 +162,41 @@ export default function ThreadCard({ thread, onThreadClick, className, subtopics

{title}

- {thread._relaySource && ( - - - {formatRelayName(thread._relaySource)} - - )}
{topicInfo.label} - {matchingSubtopics.map(subtopic => ( - + {allTopics.slice(0, 3).map(topic => ( + - {subtopic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} + {topic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} ))}
{timeAgo}
+ + {/* Vote counts */} + {totalVotes > 0 && ( +
+ 0 ? 'text-green-600' : netVotes < 0 ? 'text-red-600' : ''}> + {netVotes > 0 ? '+' : ''}{netVotes} + + {' '}{t('votes')} ({totalVotes} {t('total')}) +
+ )} + + {/* Comment count */} {commentCount > 0 && (
{commentCount} {commentCount === 1 ? t('comment') : t('comments')}
)} + + {/* Last activity */} {lastCommentAgo && (
{t('last commented')}: {lastCommentAgo} @@ -233,16 +208,6 @@ export default function ThreadCard({ thread, onThreadClick, className, subtopics
)}
- {isReadingGroup && (authorTag || subjectTag) && ( -
- {authorTag && ( - Author: {authorTag[1]} - )} - {subjectTag && ( - Book: {subjectTag[1]} - )} -
- )}
diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx index cf07d33..bd5dcf3 100644 --- a/src/pages/primary/DiscussionsPage/index.tsx +++ b/src/pages/primary/DiscussionsPage/index.tsx @@ -1,1281 +1,557 @@ -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -// Removed dropdown menu import - no longer using relay selection -import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, SEARCHABLE_RELAY_URLS, HASHTAG_REGEX, ExtendedKind } from '@/constants' -import { normalizeUrl } from '@/lib/url' -import { normalizeTopic } from '@/lib/discussion-topics' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useNostr } from '@/providers/NostrProvider' -import { forwardRef, useEffect, useState, useCallback, useRef } from 'react' +import { forwardRef, useEffect, useState, useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' -import { MessageSquarePlus, Book, BookOpen, Hash, Search, X, RefreshCw } 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 SubtopicFilter from '@/pages/primary/DiscussionsPage/SubtopicFilter' -import TopicSubscribeButton from '@/components/TopicSubscribeButton' -import { NostrEvent } from 'nostr-tools' -import client from '@/services/client.service' -import { useSmartNoteNavigation } from '@/PageManager' -import { toNote } from '@/lib/link' +import { RefreshCw } from 'lucide-react' +import { useNostr } from '@/providers/NostrProvider' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +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' +// 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 +} -// Function to determine topic based on actual t-tags and hashtags -function getTopicFromTags(allTopics: string[], availableTopicIds: string[]): string { - // Normalize topics to lowercase for case-insensitive matching - const normalizedTopics = allTopics.map(t => t.toLowerCase()) - const normalizedTopicIds = availableTopicIds.map(t => t.toLowerCase()) +// 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 - // Check if any of the event's topics match the available topic IDs (case-insensitive) - for (let i = 0; i < normalizedTopics.length; i++) { - const index = normalizedTopicIds.indexOf(normalizedTopics[i]) - if (index !== -1) { - return availableTopicIds[index] // Return the original case from availableTopicIds - } + // 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' } - // If no specific topic matches, categorize as 'general' - return 'general' -} - -// Analyze hashtag usage across events to determine dynamic topics/subtopics -function analyzeDynamicTopicsAndSubtopics(eventMap: Map): { - dynamicTopics: string[] - dynamicSubtopics: string[] -} { - // Track hashtag usage: hashtag -> { eventIds: Set, npubs: Set } - const hashtagUsage = new Map, npubs: Set }>() - - // Analyze all events - eventMap.forEach((entry) => { - entry.allTopics.forEach(topic => { - if (!hashtagUsage.has(topic)) { - hashtagUsage.set(topic, { eventIds: new Set(), npubs: new Set() }) + // 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) { + // Skip self-votes + if (reaction.pubkey === threadAuthor) return + + const normalizedReaction = normalizeReaction(reaction.content) + 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 }) + } + } } - const usage = hashtagUsage.get(topic)! - usage.eventIds.add(entry.event.id) - usage.npubs.add(entry.event.pubkey) }) }) - // Get predefined topic IDs - const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id) + // 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 + }) - const dynamicTopics: string[] = [] - const dynamicSubtopics: string[] = [] + 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 - // Analyze each hashtag - hashtagUsage.forEach((usage, hashtag) => { - // Skip if it's already a predefined topic - if (predefinedTopicIds.includes(hashtag)) { - return - } - - const eventCount = usage.eventIds.size - const npubCount = usage.npubs.size - - // If 10+ events from 10+ different npubs, make it a topic - if (eventCount >= 10 && npubCount >= 10) { - dynamicTopics.push(hashtag) - } - // If 3+ events from 3+ different npubs, make it a subtopic - else if (eventCount >= 3 && npubCount >= 3) { - dynamicSubtopics.push(hashtag) - } + 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 { dynamicTopics, dynamicSubtopics } + return { commentCount, lastCommentTime } } -// Removed getSubtopicsFromTopics - now using only dynamicSubtopics that meet npub thresholds +// Topic categorization function +function getTopicFromTags(allTopics: string[], predefinedTopicIds: string[]): string { + for (const topic of allTopics) { + if (predefinedTopicIds.includes(topic)) { + return topic + } + } + return 'general' +} -// Simple event map type -type EventMapEntry = { - event: NostrEvent - relaySources: string[] - tTags: string[] - hashtags: string[] - allTopics: string[] - categorizedTopic: string - commentCount: number - lastCommentTime: number - lastVoteTime: number +// Normalize topic function +function normalizeTopic(topic: string): string { + return topic.toLowerCase().replace(/\s+/g, '-') } -const DiscussionsPage = forwardRef((_, ref) => { +const DiscussionsPage = forwardRef(() => { const { t } = useTranslation() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { pubkey } = useNostr() - const { navigateToNote } = useSmartNoteNavigation() - - // State management - const [selectedTopic, setSelectedTopic] = useState('all') - const [selectedSubtopic, setSelectedSubtopic] = useState(null) - // Removed relay filtering - using all relays - const [selectedSort, setSelectedSort] = useState('newest') - const [allEventMap, setAllEventMap] = useState>(new Map()) // Store all threads - const [eventMap, setEventMap] = useState>(new Map()) // Filtered for display - const [filteredEvents, setFilteredEvents] = useState([]) - const [loading, setLoading] = useState(true) - const [showCreateThread, setShowCreateThread] = useState(false) - const [viewMode, setViewMode] = useState<'flat' | 'grouped'>('grouped') - const [groupedEvents, setGroupedEvents] = useState>({}) - const [searchQuery, setSearchQuery] = useState('') - // Time span selector + // State + const [allEventMap, setAllEventMap] = useState>(new Map()) + const [eventMap, setEventMap] = useState>(new Map()) const [timeSpan, setTimeSpan] = useState<'30days' | '90days' | 'all'>('30days') - - // Track counts for each time span (calculated from actual filtered results) - const [timeSpanCounts, setTimeSpanCounts] = useState<{ - '30days': number - '90days': number - 'all': number - }>({ '30days': 0, '90days': 0, 'all': 0 }) - - // Available subtopics for the selected topic - const [availableSubtopics, setAvailableSubtopics] = useState([]) - - // State for all available relays - const [allRelays, setAllRelays] = useState([]) - const isFetchingRef = useRef(false) - const lastFetchTimeRef = useRef(0) + 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) - - // Get all available relays (use favorite relays from provider + user's read relays or fast read relays) - useEffect(() => { - const updateRelays = async () => { - let userReadRelays: string[] = [] - - if (pubkey) { - try { - // Get user's read relays - const relayList = await client.fetchRelayList(pubkey) - userReadRelays = relayList?.read || [] - console.log('[DiscussionsPage] User read relays:', userReadRelays) - } catch (error) { - console.warn('Failed to fetch user relay list:', error) - } - } else { - console.log('[DiscussionsPage] No pubkey - using anonymous relay list') - } - - console.log('[DiscussionsPage] Relay sources:') - console.log(' - SEARCHABLE_RELAY_URLS:', SEARCHABLE_RELAY_URLS.length, 'relays') - console.log(' - userReadRelays:', userReadRelays.length, 'relays') - console.log(' - favoriteRelays:', favoriteRelays.length, 'relays') - console.log(' - BIG_RELAY_URLS:', BIG_RELAY_URLS.length, 'relays') - console.log(' - FAST_READ_RELAY_URLS:', FAST_READ_RELAY_URLS.length, 'relays') - console.log(' - FAST_WRITE_RELAY_URLS:', FAST_WRITE_RELAY_URLS.length, 'relays') - console.log(' - blockedRelays:', blockedRelays.length, 'relays') - - // Use a comprehensive relay list for discussions to ensure we get all topics - // Combine searchable relays + user's read relays + favorite relays + big relays + fast relays - const allRawRelays = [ - ...SEARCHABLE_RELAY_URLS, // Comprehensive list of searchable relays - ...userReadRelays, - ...favoriteRelays, - ...BIG_RELAY_URLS, // Big relays - ...FAST_READ_RELAY_URLS, // Fast read relays - ...FAST_WRITE_RELAY_URLS // Fast write relays - ] - - console.log('[DiscussionsPage] Total raw relays before processing:', allRawRelays.length) - - // Normalize and deduplicate all relays - const normalizedRelays = Array.from(new Set( - allRawRelays - .map(url => normalizeUrl(url)) - .filter(url => url && url.length > 0) // Remove any empty/invalid URLs - )) - - console.log('[DiscussionsPage] Normalized relays after deduplication:', normalizedRelays.length) - - // Filter out blocked relays - const relays = normalizedRelays.filter(relay => { - const normalizedRelay = normalizeUrl(relay) || relay - return !blockedRelays.some(blocked => { - const normalizedBlocked = normalizeUrl(blocked) || blocked - return normalizedBlocked === normalizedRelay - }) - }) - - console.log('[DiscussionsPage] Final relay list after blocking filter:', relays.length, 'relays') - console.log('[DiscussionsPage] Final relays:', relays) - - // Only update if relays actually changed - setAllRelays(prevRelays => { - const prevRelaysStr = prevRelays.sort().join(',') - const newRelaysStr = relays.sort().join(',') - if (prevRelaysStr === newRelaysStr) { - console.log('[DiscussionsPage] Relays unchanged, skipping update') - return prevRelays // No change, don't trigger re-render - } - console.log('[DiscussionsPage] Relays changed, updating state') - return relays - }) - } - - // Debounce relay updates to prevent rapid changes - const timeoutId = setTimeout(updateRelays, 500) - return () => clearTimeout(timeoutId) - }, [pubkey, favoriteRelays, blockedRelays]) - - // State for dynamic topics and subtopics - const [dynamicTopics, setDynamicTopics] = useState([]) - const [dynamicSubtopics, setDynamicSubtopics] = useState([]) + const [showCreateDialog, setShowCreateDialog] = useState(false) + const [selectedTopic, setSelectedTopic] = useState('all') - // Manual reset function for debugging - const resetFetchState = useCallback(() => { - console.log('Manually resetting fetch state') - isFetchingRef.current = false - setLoading(false) - }, []) - - // Fetch all kind 11 events from all relays - const fetchAllEvents = useCallback(async () => { - // Prevent multiple simultaneous fetches using ref to avoid dependency - if (isFetchingRef.current) { - console.log('Already fetching, skipping...') - return - } + // 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 + ] - // Prevent too frequent fetches (minimum 10 seconds between fetches) - const now = Date.now() - if (now - lastFetchTimeRef.current < 10000) { - console.log('Fetch too soon, skipping...') - return - } + // Normalize and deduplicate relay URLs + const normalizedRelays = allRelays + .map(url => normalizeUrl(url)) + .filter((url): url is string => !!url) - console.log('[DiscussionsPage] Starting fetchAllEvents...') - console.log('[DiscussionsPage] Using', allRelays.length, 'relays for fetching:', allRelays) + // Filter blocked relays + const finalRelays = normalizedRelays.filter(relay => + !blockedRelays.some(blocked => relay.includes(blocked)) + ) - isFetchingRef.current = true - lastFetchTimeRef.current = now + console.log('[DiscussionsPage] Using', finalRelays.length, 'comprehensive relays') + return Array.from(new Set(finalRelays)) + }, []) // Remove dependencies to prevent infinite loop + + // Fetch all events + const fetchAllEvents = useCallback(async () => { + if (loading) return setLoading(true) + setIsRefreshing(true) - // Safety timeout to reset fetch state if it gets stuck - const safetyTimeout = setTimeout(() => { - console.warn('[DiscussionsPage] Fetch timeout - resetting fetch state') - isFetchingRef.current = false - setLoading(false) - }, 30000) // 30 second timeout try { - // Time span calculation is now only used in the display filter layer + console.log('[DiscussionsPage] Fetching all discussion threads...') - console.log('[DiscussionsPage] Simplified approach: Fetch all kind 11, then related 1111/7, remove self-responses, process bumping, filter by', timeSpan, 'in display layer') - console.log('[DiscussionsPage] Fetching all discussion threads (no time limit)') + // Get comprehensive relay list + const allRelays = await buildComprehensiveRelayList() - // Step 1: Fetch all kind 11 (discussion threads) - no time filtering + // Step 1: Fetch all discussion threads (kind 11) const discussionThreads = await client.fetchEvents(allRelays, [ { - kinds: [ExtendedKind.DISCUSSION], // Only discussion threads + kinds: [11], // ExtendedKind.DISCUSSION limit: 500 } ]) - console.log('[DiscussionsPage] Step 1: Fetched', discussionThreads.length, 'discussion threads (kind 11)') - - // Step 2: Get all thread IDs to fetch related comments and reactions - const threadIds = discussionThreads.map(thread => thread.id) - console.log('[DiscussionsPage] Step 2: Fetching related comments and reactions for', threadIds.length, 'threads') + console.log('[DiscussionsPage] Fetched', discussionThreads.length, 'discussion threads') - // Fetch comments (kind 1111) that reference these threads - const comments = threadIds.length > 0 ? await client.fetchEvents(allRelays, [ - { - kinds: [ExtendedKind.COMMENT], - '#e': threadIds, - limit: 1000 - } - ]) : [] - - // Fetch reactions (kind 7) that reference these threads - const reactions = threadIds.length > 0 ? await client.fetchEvents(allRelays, [ - { - kinds: [kinds.Reaction], - '#e': threadIds, - limit: 1000 - } - ]) : [] + // Step 2: Get thread IDs and fetch related comments and reactions + const threadIds = discussionThreads.map((thread: NostrEvent) => thread.id) - console.log('[DiscussionsPage] Step 2: Fetched', comments.length, 'comments and', reactions.length, 'reactions for existing threads') - - // Combine all events for processing - const events = [...discussionThreads, ...comments, ...reactions] - - // Create a map of events with their relay sources - const newEventMap = new Map() - - 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 }) - } - }) - - // Get all event IDs to check for deletions - const eventIds = Array.from(newEventMap.keys()) - - // Fetch deletion events for these specific event IDs - const deletedEventIds = new Set() - if (eventIds.length > 0) { - try { - const deletionEvents = await client.fetchEvents(allRelays, [ - { - kinds: [kinds.EventDeletion], - '#e': eventIds, + const [comments, reactions] = await Promise.all([ + threadIds.length > 0 ? client.fetchEvents(allRelays, [ + { + kinds: [1111], // ExtendedKind.COMMENT + '#e': threadIds, + limit: 1000 + } + ]) : Promise.resolve([]), + threadIds.length > 0 ? client.fetchEvents(allRelays, [ + { + kinds: [kinds.Reaction], + '#e': threadIds, 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('[DiscussionsPage] Failed to fetch deletion events:', error) - } - } - - console.log('[DiscussionsPage] Found', deletedEventIds.size, 'deleted events') - console.log('[DiscussionsPage] Processing', newEventMap.size, 'events for final map') - - // Step 3: Remove self-responses and Step 4: Process thread bumping - const threadIdsToFetch = new Set() - const threadAuthors = new Map() // Map thread ID to author pubkey - - // First, collect all thread authors to exclude self-activity - newEventMap.forEach(({ event }) => { - if (event.kind === ExtendedKind.DISCUSSION) { - threadAuthors.set(event.id, event.pubkey) - } - }) - - // Step 3: Remove self-responses and identify threads to bump - newEventMap.forEach(({ event }) => { - if (event.kind === ExtendedKind.COMMENT || event.kind === kinds.Reaction) { - // Look for 'e' tags that reference discussion threads - const eTags = event.tags.filter(tag => tag[0] === 'e' && tag[1]) - eTags.forEach(tag => { - const threadId = tag[1] - if (threadId) { - // Check if this activity is from someone other than the thread author - const threadAuthor = threadAuthors.get(threadId) - if (!threadAuthor || event.pubkey !== threadAuthor) { - // This is a non-self response - if (!newEventMap.has(threadId)) { - // This comment/reaction references a thread we don't have yet - add to bump list - threadIdsToFetch.add(threadId) - } - } - // If it's a self-response, we simply don't process it further - } - }) - } - }) + ]) : Promise.resolve([]) + ]) - console.log('[DiscussionsPage] Found', threadIdsToFetch.size, 'older threads to fetch due to recent comments/reactions (excluding self-activity)') + console.log('[DiscussionsPage] Fetched', comments.length, 'comments and', reactions.length, 'reactions') - // Fetch the older threads that have recent activity - if (threadIdsToFetch.size > 0) { - try { - const olderThreads = await client.fetchEvents(allRelays, [ - { - kinds: [ExtendedKind.DISCUSSION], - ids: Array.from(threadIdsToFetch), - limit: 100 - } - ]) - - console.log('[DiscussionsPage] Fetched', olderThreads.length, 'older threads due to recent comments') - - // Add the older threads to our event map - olderThreads.forEach(event => { - const eventHints = client.getEventHints(event.id) - const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] - - if (!newEventMap.has(event.id)) { - newEventMap.set(event.id, { event, relaySources }) - } - }) - } catch (error) { - console.warn('[DiscussionsPage] Failed to fetch older threads:', error) - } + // Debug: Log some reaction details + if (reactions.length > 0) { + console.log('[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') + }))) } - // Analyze comment counts and last activity timestamps for each thread - const threadStats = new Map() + // Step 3: Build event map with vote and comment counts + const newEventMap = new Map() - newEventMap.forEach(({ event }) => { - if (event.kind === ExtendedKind.DISCUSSION) { - // Initialize thread stats - threadStats.set(event.id, { commentCount: 0, lastCommentTime: 0, lastVoteTime: 0 }) - } - }) - - // Helper function to normalize reaction content according to NIP-25 - const normalizeReactionContent = (content: string): string => { - const normalized = content.trim() - - // NIP-25: Empty string or "+" should be interpreted as "like" or "upvote" - if (normalized === '' || normalized === '+') { - return '+' - } - - // NIP-25: "-" should be interpreted as "dislike" or "downvote" - if (normalized === '-') { - return '-' - } - - // Normalize common arrow emojis to +/- for consistent counting - if (normalized === '⬆️' || normalized === '↑' || normalized === '👍' || normalized === '❤️' || normalized === '🔥') { - return '+' - } - - if (normalized === '⬇️' || normalized === '↓' || normalized === '👎' || normalized === '💩') { - return '-' - } + discussionThreads.forEach((thread: NostrEvent) => { + const threadId = thread.id + const threadAuthor = thread.pubkey - // For other emojis or custom reactions, treat as neutral (don't count as vote) - return 'emoji' - } - - // Count comments and track last activity times (excluding self-responses) - newEventMap.forEach(({ event }) => { - if (event.kind === ExtendedKind.COMMENT || event.kind === kinds.Reaction) { - const eTags = event.tags.filter(tag => tag[0] === 'e' && tag[1]) - eTags.forEach(tag => { - const threadId = tag[1] - if (threadId && threadStats.has(threadId)) { - // Check if this is a self-response - const threadAuthor = threadAuthors.get(threadId) - if (threadAuthor && event.pubkey === threadAuthor) { - // Skip self-responses - return - } - - const stats = threadStats.get(threadId)! - - if (event.kind === ExtendedKind.COMMENT) { - stats.commentCount++ - if (event.created_at > stats.lastCommentTime) { - stats.lastCommentTime = event.created_at - } - } else if (event.kind === kinds.Reaction) { - // Only count reactions that normalize to +/- as votes - const normalizedReaction = normalizeReactionContent(event.content) - if (normalizedReaction === '+' || normalizedReaction === '-') { - if (event.created_at > stats.lastVoteTime) { - stats.lastVoteTime = event.created_at - } - } - } - } - }) - } - }) - - console.log('[DiscussionsPage] Thread stats calculated:', Array.from(threadStats.entries()).map(([id, stats]) => ({ id, ...stats }))) - - // Step 5: Build the final event map with topic information (90-day filter applied) - const finalEventMap = new Map() - - // Step 5: Display kind 11s with activity newer than 90 days - newEventMap.forEach(({ event, relaySources }, eventId) => { - // Skip deleted events - if (deletedEventIds.has(eventId)) { - return - } + // Count votes and comments + const voteStats = countVotesForThread(threadId, reactions, threadAuthor) + const commentStats = countCommentsForThread(threadId, comments, threadAuthor) - // Only process discussion threads (kind 11) for display - if (event.kind !== ExtendedKind.DISCUSSION) { - return + // Debug: Log vote stats for threads with votes + if (voteStats.upVotes > 0 || voteStats.downVotes > 0) { + console.log('[DiscussionsPage] Thread', threadId.substring(0, 8), 'has votes:', voteStats) } - // Include all threads - filtering will be done in display layer - - // Extract topics - normalize subtopics but keep originals for topic detection - const tTagsRaw = event.tags.filter(tag => tag[0] === 't' && tag[1]).map(tag => tag[1].toLowerCase()) - // Match hashtags using the same regex as everywhere else - const hashtagsRaw = (event.content.match(HASHTAG_REGEX) || []).map(tag => tag.slice(1).toLowerCase()) + // 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])] - // Determine the main topic from raw tags (use only predefined topics during fetch) - const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id) + // Categorize topic + const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds) - // Debug logging for topics - if (allTopicsRaw.length === 0) { - console.log('[DiscussionsPage] Discussion with no topics categorized as:', categorizedTopic, 'Event ID:', event.id) - } - - // Normalize subtopics for grouping (but not main topic IDs) - const tTags = tTagsRaw.map(tag => normalizeTopic(tag)) - const hashtags = hashtagsRaw.map(tag => normalizeTopic(tag)) + // Normalize topics + const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag)) + const hashtags = hashtagsRaw.map((tag: string) => normalizeTopic(tag)) const allTopics = [...new Set([...tTags, ...hashtags])] - // Get thread stats for this event - const finalStats = threadStats.get(eventId) || { commentCount: 0, lastCommentTime: 0, lastVoteTime: 0 } + // Get relay sources + const eventHints = client.getEventHints(threadId) + const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] - finalEventMap.set(eventId, { - event, + newEventMap.set(threadId, { + event: thread, relaySources, tTags, hashtags, allTopics, categorizedTopic, - commentCount: finalStats.commentCount, - lastCommentTime: finalStats.lastCommentTime, - lastVoteTime: finalStats.lastVoteTime + commentCount: commentStats.commentCount, + lastCommentTime: commentStats.lastCommentTime, + lastVoteTime: voteStats.lastVoteTime, + upVotes: voteStats.upVotes, + downVotes: voteStats.downVotes }) }) - console.log('[DiscussionsPage] Step 6: Final event map size:', finalEventMap.size, 'threads with recent activity') - console.log('[DiscussionsPage] Final events:', Array.from(finalEventMap.values()).map(e => ({ id: e.event.id, content: e.event.content.substring(0, 100) + '...' }))) + console.log('[DiscussionsPage] Built event map with', newEventMap.size, 'threads') - // Store all threads in allEventMap (for counting) - setAllEventMap(finalEventMap) + // Log vote counts for debugging + newEventMap.forEach((entry, threadId) => { + if (entry.upVotes > 0 || entry.downVotes > 0) { + console.log('[DiscussionsPage] Thread', threadId.substring(0, 8) + '...', 'has', entry.upVotes, 'upvotes,', entry.downVotes, 'downvotes') + } + }) + + setAllEventMap(newEventMap) - // Analyze and set dynamic topics/subtopics from the fetched events - if (finalEventMap.size > 0) { - const { dynamicTopics: newTopics, dynamicSubtopics: newSubtopics } = analyzeDynamicTopicsAndSubtopics(finalEventMap) - console.log('[DiscussionsPage] Dynamic topics found:', newTopics) - console.log('[DiscussionsPage] Dynamic subtopics found:', newSubtopics) - setDynamicTopics(newTopics) - setDynamicSubtopics(newSubtopics) - } else { - console.log('[DiscussionsPage] No events found, clearing topics') - setDynamicTopics([]) - setDynamicSubtopics([]) - } } catch (error) { console.error('[DiscussionsPage] Error fetching events:', error) - setEventMap(new Map()) - setDynamicTopics([]) - setDynamicSubtopics([]) } finally { - clearTimeout(safetyTimeout) setLoading(false) - isFetchingRef.current = false + setIsRefreshing(false) } - }, [allRelays]) - - // Calculate counts for all time spans from the all event map + }, []) // Remove dependencies to prevent infinite loop + + // Calculate time span counts const calculateTimeSpanCounts = useCallback(() => { - if (allEventMap.size === 0) { - setTimeSpanCounts({ '30days': 0, '90days': 0, 'all': 0 }) - return - } - - const now = Math.floor(Date.now() / 1000) - const thirtyDaysAgo = now - (30 * 24 * 60 * 60) - const ninetyDaysAgo = now - (90 * 24 * 60 * 60) - - const counts = { '30days': 0, '90days': 0, 'all': 0 } - - // Count threads for each time span based on all event map + 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 { event, lastCommentTime, lastVoteTime } = entry - - // Check if thread has activity within each time span - const hasActivity30Days = lastCommentTime > thirtyDaysAgo || - lastVoteTime > thirtyDaysAgo || - event.created_at > thirtyDaysAgo + 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 + ) - const hasActivity90Days = lastCommentTime > ninetyDaysAgo || - lastVoteTime > ninetyDaysAgo || - event.created_at > ninetyDaysAgo - - if (hasActivity30Days) counts['30days']++ - if (hasActivity90Days) counts['90days']++ - // 'all' should always count every thread in the map - counts['all']++ + if (mostRecentActivity > thirtyDaysAgo) count30++ + if (mostRecentActivity > ninetyDaysAgo) count90++ + countAll++ }) - - setTimeSpanCounts(counts) - console.log('[DiscussionsPage] Time span counts calculated from all event map:', counts) + + setTimeSpanCounts({ '30days': count30, '90days': count90, 'all': countAll }) }, [allEventMap]) - - // Fetch events on component mount and periodically - useEffect(() => { - if (allRelays.length > 0) { - fetchAllEvents() - - // Refetch every 5 minutes - const interval = setInterval(fetchAllEvents, 5 * 60 * 1000) - return () => clearInterval(interval) - } - }, [allRelays, timeSpan, fetchAllEvents]) - - // Filter allEventMap based on selected timeSpan for display + + // Filter event map for display const filterEventMapForDisplay = useCallback(() => { - if (allEventMap.size === 0) { - setEventMap(new Map()) - return - } - - const now = Math.floor(Date.now() / 1000) - const thirtyDaysAgo = now - (30 * 24 * 60 * 60) - const ninetyDaysAgo = now - (90 * 24 * 60 * 60) - + 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, eventId) => { - const { event, lastCommentTime, lastVoteTime } = entry + + 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 + } - let shouldInclude = false + // Filter by topic + const passesTopicFilter = selectedTopic === 'all' || entry.categorizedTopic === selectedTopic - switch (timeSpan) { - case '30days': - shouldInclude = lastCommentTime > thirtyDaysAgo || - lastVoteTime > thirtyDaysAgo || - event.created_at > thirtyDaysAgo - break - case '90days': - shouldInclude = lastCommentTime > ninetyDaysAgo || - lastVoteTime > ninetyDaysAgo || - event.created_at > ninetyDaysAgo - break - case 'all': - shouldInclude = true // Include all threads - break - } - - if (shouldInclude) { - filteredMap.set(eventId, entry) + if (passesTimeFilter && passesTopicFilter) { + filteredMap.set(entry.event.id, entry) } }) - + setEventMap(filteredMap) - console.log('[DiscussionsPage] Filtered event map for display:', filteredMap.size, 'threads for timeSpan:', timeSpan) - }, [allEventMap, timeSpan]) - - // Calculate time span counts when all event map changes + }, [allEventMap, timeSpan, selectedTopic]) + + // Effects useEffect(() => { - calculateTimeSpanCounts() - }, [calculateTimeSpanCounts]) - - // Filter event map for display when allEventMap or timeSpan changes + fetchAllEvents() + }, []) // Only run once on mount + useEffect(() => { - filterEventMapForDisplay() - }, [filterEventMapForDisplay]) - - // Manual refresh function - const handleManualRefresh = useCallback(async () => { - if (isFetchingRef.current || allRelays.length === 0) { - return + if (allEventMap.size > 0) { + calculateTimeSpanCounts() } - - setIsRefreshing(true) - try { - await fetchAllEvents() - } finally { - setIsRefreshing(false) + }, [allEventMap]) // Run when allEventMap changes + + useEffect(() => { + if (allEventMap.size > 0) { + filterEventMapForDisplay() } - }, [fetchAllEvents, allRelays.length]) - - // Simplified filtering - no relay filtering, just return all events - const getFilteredEvents = useCallback(() => { - return Array.from(eventMap.values()).map(entry => entry.event) - }, [eventMap]) - - // Filter threads by topic and search - const filterAndSortEvents = useCallback(() => { - const events = getFilteredEvents() + }, [allEventMap, timeSpan, selectedTopic]) // Run when allEventMap, timeSpan, or selectedTopic changes + + // Get available topics sorted by most recent activity + const availableTopics = useMemo(() => { + const topicMap = new Map() - // Filter by topic - let filtered = events - if (selectedTopic !== 'all') { - filtered = events.filter(event => { - const entry = eventMap.get(event.id) - if (!entry) return false - - if (entry.categorizedTopic !== selectedTopic) return false - - if (selectedSubtopic) { - return entry.allTopics.includes(selectedSubtopic) - } - - return true - }) - } + 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)) { + topicMap.set(topic, { count: 0, lastActivity: 0 }) + } + + const current = topicMap.get(topic)! + current.count++ + current.lastActivity = Math.max(current.lastActivity, lastActivity) + }) - // 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 - } - - // Search in content - if (event.content.toLowerCase().includes(lowerQuery)) { - return true - } - - // Search in author tags - const authorTag = event.tags.find(tag => tag[0] === 'author' && tag[1]) - if (authorTag && authorTag[1]?.toLowerCase().includes(lowerQuery)) { - return true - } - - // Search in subject tags (for books) - const subjectTag = event.tags.find(tag => tag[0] === 'subject' && tag[1]) - if (subjectTag && subjectTag[1]?.toLowerCase().includes(lowerQuery)) { - return true - } - - // Search in topics - const entry = eventMap.get(event.id) - if (entry) { - return entry.allTopics.some(topic => - topic.toLowerCase().includes(lowerQuery) - ) - } - - return false - }) - } + // 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]) + + // Group events by topic + const groupedEvents = useMemo(() => { + const groups = new Map() - // 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 + eventMap.forEach((entry) => { + const topic = entry.categorizedTopic + if (!groups.has(topic)) { + groups.set(topic, []) } + groups.get(topic)!.push(entry) }) - setFilteredEvents(sorted) + // Sort groups by predefined order + const sortedGroups = Array.from(groups.entries()).sort(([a], [b]) => { + const aIndex = DISCUSSION_TOPICS.findIndex(t => t.id === a) + const bIndex = DISCUSSION_TOPICS.findIndex(t => t.id === b) + if (aIndex === -1 && bIndex === -1) return a.localeCompare(b) + if (aIndex === -1) return 1 + if (bIndex === -1) return -1 + return aIndex - bIndex + }) - // Handle grouped view - if (viewMode === 'grouped' && selectedTopic === 'all') { - 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] = [] - } - groups[topic].push(event) - return groups - }, {} as Record) - - // Debug logging for grouping - console.log('[DiscussionsPage] Grouped topics:', Object.keys(grouped).map(topic => `${topic}: ${grouped[topic].length}`).join(', ')) - - // 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]) - - // Update filtered events when dependencies change - useEffect(() => { - filterAndSortEvents() - }, [filterAndSortEvents]) - - - // Update available subtopics when topic analysis or selected topic changes - useEffect(() => { - if (selectedTopic && selectedTopic !== 'all') { - // Only show dynamic subtopics that meet the 3-npub threshold - // Don't use getSubtopicsFromTopics as it doesn't respect npub thresholds - const relevantDynamicSubtopics = dynamicSubtopics.filter(subtopic => { - // Check if this subtopic appears in events for this topic - const topicEvents = Array.from(eventMap.values()).filter(entry => entry.categorizedTopic === selectedTopic) - const appearsInTopic = topicEvents.some(entry => entry.allTopics.includes(subtopic)) - return appearsInTopic - }) - - // Special case: Always include 'readings' as a subtopic for 'literature' if it appears - if (selectedTopic === 'literature') { - const topicEvents = Array.from(eventMap.values()).filter(entry => entry.categorizedTopic === selectedTopic) - const hasReadings = topicEvents.some(entry => entry.allTopics.includes('readings')) - if (hasReadings && !relevantDynamicSubtopics.includes('readings')) { - relevantDynamicSubtopics.unshift('readings') - } - } - - setAvailableSubtopics(relevantDynamicSubtopics) - } else if (selectedTopic === 'general') { - // For General topic, show dynamic subtopics that don't belong to other topics - const generalSubtopics = dynamicSubtopics.filter(subtopic => { - // Check if this subtopic appears in general-categorized events - const appearsInGeneral = Array.from(eventMap.values()).some(entry => - entry.categorizedTopic === 'general' && entry.allTopics.includes(subtopic) - ) - return appearsInGeneral - }) - setAvailableSubtopics(generalSubtopics) - } else { - setAvailableSubtopics([]) - } - }, [eventMap, selectedTopic, dynamicSubtopics]) - - const handleCreateThread = () => { - setShowCreateThread(true) + return sortedGroups + }, [eventMap]) + + // Handle refresh + const handleRefresh = () => { + fetchAllEvents() } - - const handleThreadCreated = (publishedEvent?: NostrEvent) => { - setShowCreateThread(false) + + // Handle create thread + const handleCreateThread = (publishedEvent?: NostrEventType) => { + if (!publishedEvent) return - // If we have the published event, add it to the map immediately - if (publishedEvent) { - console.log('Adding newly published event to display:', publishedEvent.id) - - // Extract topics from the published event - const tTagsRaw = publishedEvent.tags.filter(tag => tag[0] === 't' && tag[1]).map(tag => tag[1].toLowerCase()) - const hashtagsRaw = (publishedEvent.content.match(HASHTAG_REGEX) || []).map(tag => tag.slice(1).toLowerCase()) + // 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])] - - // Determine the main topic from raw tags - const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id) + const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds) - - // Normalize subtopics for grouping using the same function as ThreadCard - const tTags = tTagsRaw.map(tag => normalizeTopic(tag)) - const hashtags = hashtagsRaw.map(tag => normalizeTopic(tag)) + const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag)) + const hashtags = hashtagsRaw.map((tag: string) => normalizeTopic(tag)) const allTopics = [...new Set([...tTags, ...hashtags])] - - // Get relay sources from event hints (tracked during publishing) - let relaySources = client.getEventHints(publishedEvent.id) - - // If no hints yet (timing issue), use the relay statuses from the published event - if (relaySources.length === 0 && (publishedEvent as any).relayStatuses) { - const successfulRelays = (publishedEvent as any).relayStatuses - .filter((status: any) => status.success) - .map((status: any) => status.url) - if (successfulRelays.length > 0) { - relaySources = successfulRelays - } - } - - // If still no sources, use first few relays - if (relaySources.length === 0) { - relaySources = allRelays.slice(0, 3) - } - - console.log('Using relay sources:', relaySources) - - // Note: Event tracking will happen automatically when the event is fetched - // from the relays during the next fetchAllEvents call. The relaySources - // are stored in the eventMap so the event can be found and displayed. - console.log('Event will be tracked automatically on next fetch from relays:', relaySources) - - // Debug: Check if the event hints are already set - const currentHints = client.getEventHints(publishedEvent.id) - console.log('Current event hints:', currentHints) - - // If no hints are set, the event wasn't properly tracked during publishing - if (currentHints.length === 0) { - console.warn('Event has no relay hints - navigation may not work properly') - } - - // Add to event map - setEventMap(prev => { - const newMap = new Map(prev) - newMap.set(publishedEvent.id, { + const eventHints = client.getEventHints(threadId) + const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] + + const newEntry: EventMapEntry = { event: publishedEvent, relaySources, tTags, hashtags, allTopics, - categorizedTopic, - commentCount: 0, - lastCommentTime: 0, - lastVoteTime: 0 - }) - return newMap - }) - - // Also update dynamic topics/subtopics if needed - setDynamicTopics(prev => { - const newTopics = [...prev] - allTopics.forEach(topic => { - if (!predefinedTopicIds.includes(topic) && !newTopics.includes(topic)) { - // This is a simplified check - full implementation would check counts - newTopics.push(topic) - } - }) - return newTopics - }) + categorizedTopic, + commentCount: 0, + lastCommentTime: 0, + lastVoteTime: 0, + upVotes: 0, + downVotes: 0 } - // Also refetch in the background to ensure we have the latest - // This will help ensure the event is properly tracked on relays - setTimeout(() => { - console.log('Background fetch after thread creation') - fetchAllEvents() - }, 3000) // Wait 3 seconds for the event to propagate + setAllEventMap(prev => new Map(prev).set(threadId, newEntry)) + + // Close the dialog + setShowCreateDialog(false) + } + + // Handle close dialog + const handleCloseDialog = () => { + setShowCreateDialog(false) } return ( - -
- ({ - id: topic, - label: topic.charAt(0).toUpperCase() + topic.slice(1), - icon: Hash - })) - ]} - selectedTopic={selectedTopic} - onTopicChange={(topic) => { - setSelectedTopic(topic) - setSelectedSubtopic(null) - }} - threads={viewMode === 'grouped' && selectedTopic === 'all' ? filteredEvents : filteredEvents} - replies={[]} - /> - {/* Removed relay selection dropdown */} -
-
- -
-
- } - displayScrollToTopButton - > -
-
-
-

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

- {selectedTopic !== 'all' && selectedTopic !== 'general' && ( - - )} -
-
- {/* Time Span Selector */} - - - {/* Refresh Button */} - - - {selectedTopic === 'all' && ( - - )} - -
+
+ {/* Header */} +
+

{t('Discussions')}

+
+ + + {/* Topic Selection Dropdown */} + + + {/* Time Span Dropdown */} + + +
- - {/* Search bar */} -
-
-
- - setSearchQuery(e.target.value)} - placeholder={t('Search by title, content, or topics...')} - className="w-full pl-10 pr-10 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" - /> - {searchQuery && ( - - )} -
-
- {/* Subtopic filter */} - {selectedTopic !== 'all' && availableSubtopics.length > 0 && ( - - )} - + {/* Content */} +
{loading ? ( -
-
{t('Loading threads...')}
-
- ) : selectedTopic !== 'all' && availableSubtopics.length > 0 && !selectedSubtopic ? ( +
{t('Loading...')}
+ ) : (
- {/* General section */} -
-
- -

{t('General')}

- - ({filteredEvents.filter(event => { - const entry = eventMap.get(event.id) - if (!entry) return false - return !availableSubtopics.some(subtopic => entry.allTopics.includes(subtopic)) - }).length} {filteredEvents.filter(event => { - const entry = eventMap.get(event.id) - if (!entry) return false - return !availableSubtopics.some(subtopic => entry.allTopics.includes(subtopic)) - }).length === 1 ? t('thread') : t('threads')}) - -
-
- {filteredEvents.filter(event => { - const entry = eventMap.get(event.id) - if (!entry) return false - return !availableSubtopics.some(subtopic => entry.allTopics.includes(subtopic)) - }).map(event => ( - { - navigateToNote(toNote(event)) - }} - /> - ))} -
-
- - {/* Dynamic subtopics sections */} - {availableSubtopics.map(subtopic => { - const subtopicEvents = filteredEvents.filter(event => { - const entry = eventMap.get(event.id) - if (!entry) return false - return entry.allTopics.includes(subtopic) - }) - - if (subtopicEvents.length === 0) return null - - const isReadingsSubtopic = subtopic === 'readings' && selectedTopic === 'literature' - - return ( -
-
- {isReadingsSubtopic ? ( - - ) : ( - - )} -

- {subtopic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} + {groupedEvents.map(([topic, events]) => ( +
+

+ {topic} ({events.length} {events.length === 1 ? t('thread') : t('threads')})

- - ({subtopicEvents.length} {subtopicEvents.length === 1 ? t('thread') : t('threads')}) - -
-
- {subtopicEvents.map(event => ( + {events.map((entry) => ( { - navigateToNote(toNote(event)) - }} + key={entry.event.id} + thread={entry.event} + commentCount={entry.commentCount} + lastCommentTime={entry.lastCommentTime} + lastVoteTime={entry.lastVoteTime} + upVotes={entry.upVotes} + downVotes={entry.downVotes} + onThreadClick={() => console.log('Thread clicked:', entry.event.id)} /> ))} -

- ) - })} -
- ) : (viewMode === 'grouped' && selectedTopic === 'all' ? - Object.keys(groupedEvents).length === 0 : - filteredEvents.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(groupedEvents).map(([topicId, topicEvents]) => { - // Skip if no events - if (topicEvents.length === 0) return null - - // Try to find topic info in predefined topics, otherwise create dynamic one - let topicInfo = DISCUSSION_TOPICS.find(t => t.id === topicId) - if (!topicInfo) { - // Check if it's a dynamic topic - if (dynamicTopics.includes(topicId)) { - topicInfo = { - id: topicId, - label: topicId.charAt(0).toUpperCase() + topicId.slice(1), - icon: Hash - } - } else { - console.warn(`Topic info not found for: ${topicId}`) - return null - } - } - - return ( -
-
- -

{topicInfo.label}

- - ({topicEvents.length} {topicEvents.length === 1 ? t('thread') : t('threads')}) - -
-
- {topicEvents.map(event => { - const entry = eventMap.get(event.id) - const threadSubtopics = entry?.categorizedTopic === 'literature' - ? ['readings'] - : entry?.allTopics || [] - - return ( - { - navigateToNote(toNote(event)) - }} - /> - ) - })} -
-
- ) - })} -
- ) : ( -
- {filteredEvents.map(event => { - const entry = eventMap.get(event.id) - const threadSubtopics = entry?.categorizedTopic === 'literature' - ? ['readings'] - : entry?.allTopics || [] - - return ( - { - navigateToNote(toNote(event)) - }} - /> - ) - })} + ))}
)}
- {showCreateThread && ( + {/* Create Thread Dialog */} + {showCreateDialog && ( setShowCreateThread(false)} - onThreadCreated={handleThreadCreated} + onClose={handleCloseDialog} + onThreadCreated={handleCreateThread} /> )} - +
) }) DiscussionsPage.displayName = 'DiscussionsPage' + export default DiscussionsPage