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

1039 lines
39 KiB

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<string, { type: string, created_at: number }>()
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<EventMapEntry[]> {
if (!query.trim()) return entries
const searchTerm = query.toLowerCase().trim()
// Search for profiles that match the query
const matchingPubkeys = new Set<string>()
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<string, number>()
const groupCounts = new Map<string, number>()
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 (
<div className="flex items-center gap-2 px-3 h-full">
<h1 className="text-lg font-semibold">{t('Discussions')}</h1>
</div>
)
}
const DiscussionsPage = forwardRef((_, ref) => {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { pubkey } = useNostr()
const { navigateToNote } = useSmartNoteNavigation()
// State
const [allEventMap, setAllEventMap] = useState<Map<string, EventMapEntry>>(new Map())
const [eventMap, setEventMap] = useState<Map<string, EventMapEntry>>(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<string>('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<string, EventMapEntry>()
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<string, EventMapEntry>()
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<string, EventMapEntry>()
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<string, { count: number, lastActivity: number, isDynamic: boolean, isMainTopic: boolean, isSubtopic: boolean }>()
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<EventMapEntry[]>([])
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<string, {
entries: EventMapEntry[]
subtopics: Map<string, EventMapEntry[]>
}>()
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<string, { entries: EventMapEntry[], subtopics: Map<string, EventMapEntry[]> }>()
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 (
<PrimaryPageLayout
ref={ref}
pageName="discussions"
titlebar={<DiscussionsPageTitlebar />}
displayScrollToTopButton
>
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<button
onClick={() => setShowCreateDialog(true)}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 w-full sm:w-auto"
>
{t('Create Thread')}
</button>
</div>
{/* Search Bar */}
<div className="relative">
{isSearching ? (
<RefreshCw className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 animate-spin" />
) : (
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
)}
<input
type="text"
placeholder={t('Search threads by title, content, tags, npub, author...')}
value={searchQuery}
onChange={(e) => 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"
/>
</div>
{/* Filters - Stack on mobile, row on desktop */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
{/* Topic Selection Dropdown */}
<select
value={selectedTopic}
onChange={(e) => setSelectedTopic(e.target.value)}
className="w-full sm:w-auto px-3 py-2 bg-white dark:bg-gray-800 text-black dark:text-white border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Topics ({allEventMap.size})</option>
{availableTopics.map(({ topic, count, isDynamic, isMainTopic, isSubtopic }) => (
<option key={topic} value={topic}>
{isDynamic && isMainTopic ? '🔥 ' : ''}
{isDynamic && isSubtopic ? '📌 ' : ''}
{topic} ({count})
{isDynamic && isMainTopic ? ' [Main Topic]' : ''}
{isDynamic && isSubtopic ? ' [Subtopic]' : ''}
</option>
))}
</select>
{/* Time Span Dropdown */}
<select
value={timeSpan}
onChange={(e) => setTimeSpan(e.target.value as '30days' | '90days' | 'all')}
className="w-full sm:w-auto px-3 py-2 bg-white dark:bg-gray-800 text-black dark:text-white border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="30days">30 days ({timeSpanCounts['30days']})</option>
<option value="90days">90 days ({timeSpanCounts['90days']})</option>
<option value="all">All found ({timeSpanCounts.all})</option>
</select>
{/* Refresh Button */}
<button
onClick={handleRefresh}
disabled={loading}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded w-full sm:w-auto flex items-center justify-center sm:justify-start"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<span className="ml-2 sm:hidden">{t('Refresh')}</span>
</button>
</div>
</div>
{/* Content */}
<div className="p-2 sm:p-4 pb-20 sm:pb-4">
{loading ? (
<div className="text-center py-8">{t('Loading...')}</div>
) : isSearching ? (
<div className="text-center py-8">{t('Searching...')}</div>
) : (
<div className="space-y-6 pb-8">
{Array.from(groupedEvents.entries()).map(([mainTopic, group]) => {
const topicInfo = availableTopics.find(t => t.topic === mainTopic)
const isDynamicMain = topicInfo?.isDynamic && topicInfo?.isMainTopic
return (
<div key={mainTopic} className="space-y-4">
{/* Main Topic Header */}
<h2 className="text-lg font-semibold mb-3 capitalize flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
<span className="flex items-center gap-2">
{isDynamicMain && <span className="text-orange-500">🔥</span>}
{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')})
</span>
{isDynamicMain && <span className="text-xs bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 px-2 py-1 rounded w-fit">Main Topic</span>}
</h2>
{/* Main Topic Threads */}
{group.entries.length > 0 && (
<div className="space-y-3">
{group.entries.map((entry) => (
<ThreadCard
key={entry.event.id}
thread={entry.event}
lastCommentTime={entry.lastCommentTime}
lastVoteTime={entry.lastVoteTime}
upVotes={entry.upVotes}
downVotes={entry.downVotes}
onThreadClick={() => handleThreadClick(entry.event.id)}
/>
))}
</div>
)}
{/* Subtopic Groups */}
{group.subtopics.size > 0 && (
<div className="ml-2 sm:ml-4 space-y-4">
{Array.from(group.subtopics.entries()).map(([subtopic, subtopicEvents]) => {
const subtopicInfo = availableTopics.find(t => t.topic === subtopic)
const isSubtopicDynamic = subtopicInfo?.isDynamic && subtopicInfo?.isSubtopic
return (
<div key={subtopic} className="space-y-2">
<h3 className="text-sm sm:text-md font-medium capitalize flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-muted-foreground">
<span className="flex items-center gap-2">
{isSubtopicDynamic && <span className="text-blue-500">📌</span>}
{subtopic} ({subtopicEvents.length} {subtopicEvents.length === 1 ? t('thread') : t('threads')})
</span>
{isSubtopicDynamic && <span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded w-fit">Subtopic</span>}
</h3>
<div className="space-y-3">
{subtopicEvents.map((entry) => (
<ThreadCard
key={entry.event.id}
thread={entry.event}
lastCommentTime={entry.lastCommentTime}
lastVoteTime={entry.lastVoteTime}
upVotes={entry.upVotes}
downVotes={entry.downVotes}
onThreadClick={() => handleThreadClick(entry.event.id)}
/>
))}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Create Thread Dialog */}
{showCreateDialog && (
<CreateThreadDialog
topic="general"
availableRelays={[]}
relaySets={[]}
dynamicTopics={dynamicTopics}
onClose={handleCloseDialog}
onThreadCreated={handleCreateThread}
/>
)}
</PrimaryPageLayout>
)
})
DiscussionsPage.displayName = 'DiscussionsPage'
export default DiscussionsPage