Browse Source

dynamic topics and search, ordering newest-first

imwald
Silberengel 5 months ago
parent
commit
2f20e3efb6
  1. 70
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  2. 335
      src/pages/primary/DiscussionsPage/index.tsx

70
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Checkbox } from '@/components/ui/checkbox'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Hash, X, Users, Code, Coins, Newspaper, BookOpen, Scroll, Cpu, Trophy, Film, Heart, TrendingUp, Utensils, MapPin, Home, PawPrint, Shirt, Image, Zap, Settings, Book, Network, Car, Eye, Edit3 } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -45,11 +45,25 @@ function buildClientTag(): string[] { @@ -45,11 +45,25 @@ function buildClientTag(): string[] {
}
interface DynamicTopic {
id: string
label: string
count: number
isMainTopic: boolean
isSubtopic: boolean
parentTopic?: string
}
interface CreateThreadDialogProps {
topic: string
availableRelays: string[]
relaySets: TRelaySet[]
selectedRelay?: string | null // null = "All relays", relay set ID, or single relay URL
dynamicTopics?: {
mainTopics: DynamicTopic[]
subtopics: DynamicTopic[]
allTopics: DynamicTopic[]
}
onClose: () => void
onThreadCreated: (publishedEvent?: NostrEvent) => void
}
@ -80,7 +94,8 @@ export default function CreateThreadDialog({ @@ -80,7 +94,8 @@ export default function CreateThreadDialog({
topic: initialTopic,
availableRelays,
relaySets,
selectedRelay: initialRelay,
selectedRelay: initialRelay,
dynamicTopics,
onClose,
onThreadCreated
}: CreateThreadDialogProps) {
@ -89,7 +104,7 @@ export default function CreateThreadDialog({ @@ -89,7 +104,7 @@ export default function CreateThreadDialog({
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [selectedTopic] = useState(initialTopic)
const [selectedTopic, setSelectedTopic] = useState(initialTopic)
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
const [selectableRelays, setSelectableRelays] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
@ -106,6 +121,33 @@ export default function CreateThreadDialog({ @@ -106,6 +121,33 @@ export default function CreateThreadDialog({
const [subject, setSubject] = useState('')
const [showReadingsPanel, setShowReadingsPanel] = useState(false)
// Create combined topics list (predefined + dynamic)
const allAvailableTopics = useMemo(() => {
const combined = [...DISCUSSION_TOPICS]
if (dynamicTopics) {
// Add dynamic main topics
dynamicTopics.mainTopics.forEach(dynamicTopic => {
combined.push({
id: dynamicTopic.id,
label: `${dynamicTopic.label} (${dynamicTopic.count}) 🔥`,
icon: Hash // Use Hash icon for dynamic topics
})
})
// Add dynamic subtopics
dynamicTopics.subtopics.forEach(dynamicTopic => {
combined.push({
id: dynamicTopic.id,
label: `${dynamicTopic.label} (${dynamicTopic.count}) 📌`,
icon: Hash // Use Hash icon for dynamic topics
})
})
}
return combined
}, [dynamicTopics])
// Initialize selected relays using the centralized relay selection service
useEffect(() => {
const initializeRelays = async () => {
@ -331,7 +373,7 @@ export default function CreateThreadDialog({ @@ -331,7 +373,7 @@ export default function CreateThreadDialog({
}
}
const selectedTopicInfo = DISCUSSION_TOPICS.find(t => t.id === selectedTopic) || DISCUSSION_TOPICS[0]
const selectedTopicInfo = allAvailableTopics.find(t => t.id === selectedTopic) || allAvailableTopics[0]
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4">
@ -353,14 +395,20 @@ export default function CreateThreadDialog({ @@ -353,14 +395,20 @@ export default function CreateThreadDialog({
{/* Topic Selection */}
<div className="space-y-2">
<Label htmlFor="topic">{t('Topic')}</Label>
<div className="flex items-center gap-2">
<selectedTopicInfo.icon className="w-4 h-4" />
<Badge variant="secondary" className="text-sm">
{selectedTopicInfo.label}
</Badge>
</div>
<select
id="topic"
value={selectedTopic}
onChange={(e) => setSelectedTopic(e.target.value)}
className="w-full 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"
>
{allAvailableTopics.map((topic) => (
<option key={topic.id} value={topic.id}>
{topic.label}
</option>
))}
</select>
<p className="text-sm text-muted-foreground">
{t('Threads are organized by topics. You can change this after creation.')}
{t('Threads are organized by topics. Choose a topic that best fits your discussion.')}
</p>
</div>

335
src/pages/primary/DiscussionsPage/index.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { forwardRef, useEffect, useState, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react'
import { RefreshCw, Search } from 'lucide-react'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useSmartNoteNavigation } from '@/PageManager'
@ -130,6 +130,145 @@ function normalizeTopic(topic: string): string { @@ -130,6 +130,145 @@ 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
let 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 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)
}
})
})
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)
}
})
// 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]
return { mainTopics, subtopics, allTopics }
}
// Enhanced topic categorization with dynamic topics
function getEnhancedTopicFromTags(allTopics: string[], predefinedTopicIds: string[], dynamicTopics: DynamicTopic[]): string {
// First check predefined 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
}
}
// Finally check dynamic subtopics
for (const topic of allTopics) {
const dynamicTopic = dynamicTopics.find(dt => dt.id === topic && dt.isSubtopic)
if (dynamicTopic) {
return topic
}
}
return 'general'
}
const DiscussionsPage = forwardRef(() => {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -145,6 +284,12 @@ const DiscussionsPage = forwardRef(() => { @@ -145,6 +284,12 @@ const DiscussionsPage = forwardRef(() => {
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 () => {
@ -247,7 +392,7 @@ const DiscussionsPage = forwardRef(() => { @@ -247,7 +392,7 @@ const DiscussionsPage = forwardRef(() => {
const hashtagsRaw = (thread.content.match(/#\w+/g) || []).map((tag: string) => tag.slice(1).toLowerCase())
const allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])]
// Categorize topic
// Categorize topic (will be updated after dynamic topics are analyzed)
const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id)
const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds)
@ -284,7 +429,23 @@ const DiscussionsPage = forwardRef(() => { @@ -284,7 +429,23 @@ const DiscussionsPage = forwardRef(() => {
}
})
setAllEventMap(newEventMap)
// Analyze dynamic topics
const 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)
updatedEventMap.set(threadId, {
...entry,
categorizedTopic: enhancedTopic
})
})
setAllEventMap(updatedEventMap)
} catch (error) {
logger.error('[DiscussionsPage] Error fetching events:', error)
@ -360,7 +521,7 @@ const DiscussionsPage = forwardRef(() => { @@ -360,7 +521,7 @@ const DiscussionsPage = forwardRef(() => {
})
setEventMap(filteredMap)
}, [allEventMap, timeSpan, selectedTopic])
}, [allEventMap, timeSpan, selectedTopic, searchQuery])
// Effects
useEffect(() => {
@ -379,9 +540,9 @@ const DiscussionsPage = forwardRef(() => { @@ -379,9 +540,9 @@ const DiscussionsPage = forwardRef(() => {
}
}, [allEventMap, timeSpan, selectedTopic]) // Run when allEventMap, timeSpan, or selectedTopic changes
// Get available topics sorted by most recent activity
// Get available topics sorted by most recent activity (including dynamic topics)
const availableTopics = useMemo(() => {
const topicMap = new Map<string, { count: number, lastActivity: number }>()
const topicMap = new Map<string, { count: number, lastActivity: number, isDynamic: boolean, isMainTopic: boolean, isSubtopic: boolean }>()
allEventMap.forEach((entry) => {
const topic = entry.categorizedTopic
@ -392,7 +553,14 @@ const DiscussionsPage = forwardRef(() => { @@ -392,7 +553,14 @@ const DiscussionsPage = forwardRef(() => {
)
if (!topicMap.has(topic)) {
topicMap.set(topic, { count: 0, lastActivity: 0 })
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)!
@ -404,13 +572,42 @@ const DiscussionsPage = forwardRef(() => { @@ -404,13 +572,42 @@ const DiscussionsPage = forwardRef(() => {
return Array.from(topicMap.entries())
.map(([topic, data]) => ({ topic, ...data }))
.sort((a, b) => b.lastActivity - a.lastActivity)
}, [allEventMap])
}, [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
const groupedEvents = useMemo(() => {
const groups = new Map<string, EventMapEntry[]>()
eventMap.forEach((entry) => {
searchedEntries.forEach((entry) => {
const topic = entry.categorizedTopic
if (!groups.has(topic)) {
groups.set(topic, [])
@ -418,18 +615,45 @@ const DiscussionsPage = forwardRef(() => { @@ -418,18 +615,45 @@ const DiscussionsPage = forwardRef(() => {
groups.get(topic)!.push(entry)
})
// 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
// Sort threads within each group by newest-first (most recent activity)
groups.forEach((entries) => {
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
})
})
// Sort groups by most recent activity (newest first)
const sortedGroups = Array.from(groups.entries()).sort(([, aEntries], [, bEntries]) => {
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
})
return sortedGroups
}, [eventMap])
}, [searchedEntries])
// Handle refresh
const handleRefresh = () => {
@ -486,9 +710,10 @@ const DiscussionsPage = forwardRef(() => { @@ -486,9 +710,10 @@ const DiscussionsPage = forwardRef(() => {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h1 className="text-2xl font-bold">{t('Discussions')}</h1>
<div className="flex items-center gap-3">
<div className="flex flex-col gap-4 p-4 border-b">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('Discussions')}</h1>
<div className="flex items-center gap-3">
<button
onClick={() => setShowCreateDialog(true)}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
@ -503,9 +728,13 @@ const DiscussionsPage = forwardRef(() => { @@ -503,9 +728,13 @@ const DiscussionsPage = forwardRef(() => {
className="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 }) => (
{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>
@ -530,34 +759,63 @@ const DiscussionsPage = forwardRef(() => { @@ -530,34 +759,63 @@ const DiscussionsPage = forwardRef(() => {
</button>
</div>
</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>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-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">
{groupedEvents.map(([topic, events]) => (
<div key={topic}>
<h2 className="text-lg font-semibold mb-3 capitalize">
{topic} ({events.length} {events.length === 1 ? t('thread') : t('threads')})
</h2>
{groupedEvents.map(([topic, events]) => {
const topicInfo = availableTopics.find(t => t.topic === topic)
const isDynamicMain = topicInfo?.isDynamic && topicInfo?.isMainTopic
const isDynamicSubtopic = topicInfo?.isDynamic && topicInfo?.isSubtopic
return (
<div key={topic}>
<h2 className="text-lg font-semibold mb-3 capitalize flex items-center gap-2">
{isDynamicMain && <span className="text-orange-500">🔥</span>}
{isDynamicSubtopic && <span className="text-blue-500">📌</span>}
{topic} ({events.length} {events.length === 1 ? t('thread') : t('threads')})
{isDynamicMain && <span className="text-xs bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 px-2 py-1 rounded">Main Topic</span>}
{isDynamicSubtopic && <span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">Subtopic</span>}
</h2>
<div className="space-y-3">
{events.map((entry) => (
{events.map((entry) => (
<ThreadCard
key={entry.event.id}
thread={entry.event}
commentCount={entry.commentCount}
lastCommentTime={entry.lastCommentTime}
lastVoteTime={entry.lastVoteTime}
upVotes={entry.upVotes}
downVotes={entry.downVotes}
onThreadClick={() => handleThreadClick(entry.event.id)}
key={entry.event.id}
thread={entry.event}
commentCount={entry.commentCount}
lastCommentTime={entry.lastCommentTime}
lastVoteTime={entry.lastVoteTime}
upVotes={entry.upVotes}
downVotes={entry.downVotes}
onThreadClick={() => handleThreadClick(entry.event.id)}
/>
))}
</div>
</div>
</div>
))}
)
})}
</div>
)}
</div>
@ -568,6 +826,7 @@ const DiscussionsPage = forwardRef(() => { @@ -568,6 +826,7 @@ const DiscussionsPage = forwardRef(() => {
topic="general"
availableRelays={[]}
relaySets={[]}
dynamicTopics={dynamicTopics}
onClose={handleCloseDialog}
onThreadCreated={handleCreateThread}
/>

Loading…
Cancel
Save