Browse Source

dynamic subtopics

imwald
Silberengel 5 months ago
parent
commit
af1b686c53
  1. 160
      src/lib/discussion-topics.ts
  2. 17
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  3. 64
      src/pages/primary/DiscussionsPage/SubtopicFilter.tsx
  4. 143
      src/pages/primary/DiscussionsPage/index.tsx

160
src/lib/discussion-topics.ts

@ -0,0 +1,160 @@
import { HASHTAG_REGEX } from '@/constants'
import { NostrEvent } from 'nostr-tools'
/**
* Normalize a topic string to lowercase with hyphens, no spaces
*/
export function normalizeTopic(topic: string): string {
return topic
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
/**
* Extract hashtags from content
*/
export function extractHashtagsFromContent(content: string): string[] {
const matches = content.matchAll(HASHTAG_REGEX)
const hashtags: string[] = []
for (const match of matches) {
// Remove the # prefix and normalize
const tag = match[0].substring(1)
hashtags.push(normalizeTopic(tag))
}
return hashtags
}
/**
* Extract t-tags from event tags
*/
export function extractTTagsFromEvent(event: NostrEvent): string[] {
return event.tags
.filter(tag => tag[0] === 't' && tag[1])
.map(tag => normalizeTopic(tag[1]))
}
/**
* Extract all topics (both hashtags and t-tags) from an event
*/
export function extractAllTopics(event: NostrEvent): string[] {
const hashtags = extractHashtagsFromContent(event.content)
const tTags = extractTTagsFromEvent(event)
// Combine and deduplicate
const allTopics = [...new Set([...hashtags, ...tTags])]
return allTopics
}
/**
* Group threads by their primary topic and collect subtopic statistics
*/
export interface TopicAnalysis {
primaryTopic: string
subtopics: Map<string, Set<string>> // subtopic -> set of npubs
threads: NostrEvent[]
}
export function analyzeThreadTopics(
threads: NostrEvent[],
availableTopicIds: string[]
): Map<string, TopicAnalysis> {
const topicMap = new Map<string, TopicAnalysis>()
for (const thread of threads) {
const allTopics = extractAllTopics(thread)
// Find the primary topic (first match from available topics)
let primaryTopic = 'general'
for (const topic of allTopics) {
if (availableTopicIds.includes(topic)) {
primaryTopic = topic
break
}
}
// Get or create topic analysis
if (!topicMap.has(primaryTopic)) {
topicMap.set(primaryTopic, {
primaryTopic,
subtopics: new Map(),
threads: []
})
}
const analysis = topicMap.get(primaryTopic)!
analysis.threads.push(thread)
// Track subtopics (all topics except the primary one and 'general'/'all')
const subtopics = allTopics.filter(
t => t !== primaryTopic && t !== 'general' && t !== 'all' && t !== 'all-topics'
)
for (const subtopic of subtopics) {
if (!analysis.subtopics.has(subtopic)) {
analysis.subtopics.set(subtopic, new Set())
}
analysis.subtopics.get(subtopic)!.add(thread.pubkey)
}
}
return topicMap
}
/**
* Get dynamic subtopics for a given main topic
* Returns subtopics that have been used by more than minNpubs unique npubs
*/
export function getDynamicSubtopics(
analysis: TopicAnalysis | undefined,
minNpubs: number = 3
): string[] {
if (!analysis) return []
const subtopics: string[] = []
for (const [subtopic, npubs] of analysis.subtopics.entries()) {
if (npubs.size >= minNpubs) {
subtopics.push(subtopic)
}
}
// Sort alphabetically
return subtopics.sort()
}
/**
* Check if a thread matches a specific subtopic
*/
export function threadMatchesSubtopic(
thread: NostrEvent,
subtopic: string
): boolean {
const allTopics = extractAllTopics(thread)
return allTopics.includes(subtopic)
}
/**
* Get the categorized topic for a thread
*/
export function getCategorizedTopic(
thread: NostrEvent,
availableTopicIds: string[]
): string {
const allTopics = extractAllTopics(thread)
// Find the first matching topic from available topics
for (const topic of allTopics) {
if (availableTopicIds.includes(topic)) {
return topic
}
}
return 'general'
}

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

@ -15,6 +15,7 @@ import { TDraftEvent } from '@/types'
import { prefixNostrAddresses } from '@/lib/nostr-address' import { prefixNostrAddresses } from '@/lib/nostr-address'
import { showPublishingError } from '@/lib/publishing-feedback' import { showPublishingError } from '@/lib/publishing-feedback'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics'
// Utility functions for thread creation // Utility functions for thread creation
function extractImagesFromContent(content: string): string[] { function extractImagesFromContent(content: string): string[] {
@ -145,16 +146,30 @@ export default function CreateThreadDialog({
// Extract images from processed content // Extract images from processed content
const images = extractImagesFromContent(processedContent) const images = extractImagesFromContent(processedContent)
// Extract hashtags from content
const hashtags = extractHashtagsFromContent(processedContent)
// Build tags array // Build tags array
const tags = [ const tags = [
['title', title.trim()], ['title', title.trim()],
['t', selectedTopic], ['t', normalizeTopic(selectedTopic)],
['-'] // Required tag for relay privacy ['-'] // Required tag for relay privacy
] ]
// Add hashtags as t-tags (deduplicate with selectedTopic)
const uniqueHashtags = hashtags.filter(
hashtag => hashtag !== normalizeTopic(selectedTopic)
)
for (const hashtag of uniqueHashtags) {
tags.push(['t', hashtag])
}
// Add readings tags if this is a reading group // Add readings tags if this is a reading group
if (isReadingGroup) { if (isReadingGroup) {
// Only add if not already added from hashtags
if (!uniqueHashtags.includes('readings')) {
tags.push(['t', 'readings']) tags.push(['t', 'readings'])
}
tags.push(['author', author.trim()]) tags.push(['author', author.trim()])
tags.push(['subject', subject.trim()]) tags.push(['subject', subject.trim()])
} }

64
src/pages/primary/DiscussionsPage/SubtopicFilter.tsx

@ -0,0 +1,64 @@
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface SubtopicFilterProps {
subtopics: string[]
selectedSubtopic: string | null
onSubtopicChange: (subtopic: string | null) => void
}
export default function SubtopicFilter({
subtopics,
selectedSubtopic,
onSubtopicChange
}: SubtopicFilterProps) {
const { t } = useTranslation()
if (subtopics.length === 0) return null
const formatSubtopicLabel = (subtopic: string): string => {
return subtopic
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
return (
<div className="flex gap-2 flex-wrap items-center">
<span className="text-sm text-muted-foreground">{t('Filter by')}:</span>
<Badge
variant={selectedSubtopic === null ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => onSubtopicChange(null)}
>
{t('All')}
</Badge>
{subtopics.map(subtopic => (
<Badge
key={subtopic}
variant={selectedSubtopic === subtopic ? 'default' : 'outline'}
className="cursor-pointer flex items-center gap-1"
onClick={() => onSubtopicChange(subtopic)}
>
{formatSubtopicLabel(subtopic)}
{selectedSubtopic === subtopic && (
<Button
variant="ghost"
size="icon"
className="h-3 w-3 p-0 hover:bg-transparent"
onClick={(e) => {
e.stopPropagation()
onSubtopicChange(null)
}}
>
<X className="h-2 w-2" />
</Button>
)}
</Badge>
))}
</div>
)
}

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

@ -7,18 +7,25 @@ import { useNostr } from '@/providers/NostrProvider'
import { forwardRef, useEffect, useState, useCallback, useMemo } from 'react' import { forwardRef, useEffect, useState, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { MessageSquarePlus, Book, BookOpen } from 'lucide-react' import { MessageSquarePlus, Book, BookOpen, Hash } from 'lucide-react'
import ThreadCard from '@/pages/primary/DiscussionsPage/ThreadCard' import ThreadCard from '@/pages/primary/DiscussionsPage/ThreadCard'
import TopicFilter from '@/pages/primary/DiscussionsPage/TopicFilter' import TopicFilter from '@/pages/primary/DiscussionsPage/TopicFilter'
import ThreadSort, { SortOption } from '@/pages/primary/DiscussionsPage/ThreadSort' import ThreadSort, { SortOption } from '@/pages/primary/DiscussionsPage/ThreadSort'
import CreateThreadDialog, { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/CreateThreadDialog' import CreateThreadDialog, { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/CreateThreadDialog'
import ViewToggle from '@/pages/primary/DiscussionsPage/ViewToggle' import ViewToggle from '@/pages/primary/DiscussionsPage/ViewToggle'
import SubtopicFilter from '@/pages/primary/DiscussionsPage/SubtopicFilter'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import client from '@/services/client.service' import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import {
analyzeThreadTopics,
getCategorizedTopic,
getDynamicSubtopics,
extractAllTopics
} from '@/lib/discussion-topics'
const DiscussionsPage = forwardRef((_, ref) => { const DiscussionsPage = forwardRef((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -42,6 +49,10 @@ const DiscussionsPage = forwardRef((_, ref) => {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [filterBy, setFilterBy] = useState<'author' | 'subject' | 'all'>('all') const [filterBy, setFilterBy] = useState<'author' | 'subject' | 'all'>('all')
// Topic analysis for dynamic subtopics
const [topicAnalysis, setTopicAnalysis] = useState<ReturnType<typeof analyzeThreadTopics>>(new Map())
const [availableSubtopics, setAvailableSubtopics] = useState<string[]>([])
// Use DEFAULT_FAVORITE_RELAYS for logged-out users, or user's favorite relays for logged-in users // Use DEFAULT_FAVORITE_RELAYS for logged-out users, or user's favorite relays for logged-in users
const availableRelays = useMemo(() => const availableRelays = useMemo(() =>
pubkey && favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS, pubkey && favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS,
@ -156,6 +167,26 @@ const DiscussionsPage = forwardRef((_, ref) => {
fetchAllThreads() fetchAllThreads()
}, [fetchAllThreads]) }, [fetchAllThreads])
// Analyze topics whenever threads change
useEffect(() => {
if (allThreads.length > 0) {
const analysis = analyzeThreadTopics(allThreads, availableTopicIds)
setTopicAnalysis(analysis)
} else {
setTopicAnalysis(new Map())
}
}, [allThreads, availableTopicIds])
// Update available subtopics when topic analysis or selected topic changes
useEffect(() => {
if (selectedTopic && selectedTopic !== 'all') {
const subtopics = getDynamicSubtopics(topicAnalysis.get(selectedTopic), 3)
setAvailableSubtopics(subtopics)
} else {
setAvailableSubtopics([])
}
}, [topicAnalysis, selectedTopic])
useEffect(() => { useEffect(() => {
// Only wait for stats for vote-based sorting // Only wait for stats for vote-based sorting
if ((selectedSort === 'top' || selectedSort === 'controversial') && !statsLoaded) { if ((selectedSort === 'top' || selectedSort === 'controversial') && !statsLoaded) {
@ -213,30 +244,20 @@ const DiscussionsPage = forwardRef((_, ref) => {
const filterThreadsByTopic = useCallback(() => { const filterThreadsByTopic = useCallback(() => {
const categorizedThreads = allThreads.map(thread => { const categorizedThreads = allThreads.map(thread => {
// Find all 't' tags in the thread // Use new function to get categorized topic (considers both hashtags and t-tags)
const topicTags = thread.tags.filter(tag => tag[0] === 't' && tag[1]) const matchedTopic = getCategorizedTopic(thread, availableTopicIds)
// Find the first matching topic from our available topics // Get all topics (hashtags + t-tags) for this thread
let matchedTopic = 'general' // Default to general const allTopics = extractAllTopics(thread)
let isReadingGroup = false
for (const topicTag of topicTags) { // Check if this is a reading group thread (special subtopic for literature)
if (availableTopicIds.includes(topicTag[1])) { const isReadingGroup = allTopics.includes('readings')
matchedTopic = topicTag[1]
break // Use the first match found
}
}
// Check if this is a reading group thread
if (matchedTopic === 'literature') {
const readingsTag = thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')
isReadingGroup = !!readingsTag
}
return { return {
...thread, ...thread,
_categorizedTopic: matchedTopic, _categorizedTopic: matchedTopic,
_isReadingGroup: isReadingGroup _isReadingGroup: isReadingGroup,
_allTopics: allTopics
} }
}) })
@ -244,27 +265,24 @@ const DiscussionsPage = forwardRef((_, ref) => {
let threadsForTopic = selectedTopic === 'all' let threadsForTopic = selectedTopic === 'all'
? categorizedThreads.map(thread => { ? categorizedThreads.map(thread => {
// Remove the temporary categorization property but keep relay source // Remove the temporary categorization property but keep relay source
const { _categorizedTopic, _isReadingGroup, ...cleanThread } = thread const { _categorizedTopic, _isReadingGroup, _allTopics, ...cleanThread } = thread
return cleanThread return cleanThread
}) })
: categorizedThreads : categorizedThreads
.filter(thread => { .filter(thread => {
if (thread._categorizedTopic !== selectedTopic) return false if (thread._categorizedTopic !== selectedTopic) return false
// Handle subtopic filtering for literature // Handle subtopic filtering
if (selectedTopic === 'literature' && selectedSubtopic) { if (selectedSubtopic) {
if (selectedSubtopic === 'readings') { // Check if thread matches the selected subtopic
return thread._isReadingGroup return thread._allTopics.includes(selectedSubtopic)
} else if (selectedSubtopic === 'general') {
return !thread._isReadingGroup
}
} }
return true return true
}) })
.map(thread => { .map(thread => {
// Remove the temporary categorization property but keep relay source // Remove the temporary categorization property but keep relay source
const { _categorizedTopic, _isReadingGroup, ...cleanThread } = thread const { _categorizedTopic, _isReadingGroup, _allTopics, ...cleanThread } = thread
return cleanThread return cleanThread
}) })
@ -357,7 +375,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
groups[topic] = [] groups[topic] = []
} }
// Remove the temporary categorization property but keep relay source // Remove the temporary categorization property but keep relay source
const { _categorizedTopic, ...cleanThread } = thread const { _categorizedTopic, _isReadingGroup, _allTopics, ...cleanThread } = thread
groups[topic].push(cleanThread) groups[topic].push(cleanThread)
return groups return groups
}, {} as Record<string, NostrEvent[]>) }, {} as Record<string, NostrEvent[]>)
@ -525,23 +543,42 @@ const DiscussionsPage = forwardRef((_, ref) => {
</div> </div>
</div> </div>
{/* Subtopic filter */}
{selectedTopic !== 'all' && availableSubtopics.length > 0 && (
<SubtopicFilter
subtopics={availableSubtopics}
selectedSubtopic={selectedSubtopic}
onSubtopicChange={setSelectedSubtopic}
/>
)}
{loading ? ( {loading ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<div className="text-muted-foreground">{t('Loading threads...')}</div> <div className="text-muted-foreground">{t('Loading threads...')}</div>
</div> </div>
) : selectedTopic === 'literature' ? ( ) : selectedTopic !== 'all' && availableSubtopics.length > 0 && !selectedSubtopic ? (
<div className="space-y-6"> <div className="space-y-6">
{/* General Literature and Arts Section */} {/* General section for the main topic (without subtopics) */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b"> <div className="flex items-center gap-2 pb-2 border-b">
<BookOpen className="w-5 h-5 text-primary" /> <BookOpen className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t('General Topics')}</h2> <h2 className="text-lg font-semibold">{t('General')}</h2>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
({threads.filter(thread => !thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).length} {threads.filter(thread => !thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).length === 1 ? t('thread') : t('threads')}) ({threads.filter(thread => {
const allTopics = extractAllTopics(thread)
// Threads that don't have any of the available subtopics
return !availableSubtopics.some(subtopic => allTopics.includes(subtopic))
}).length} {threads.filter(thread => {
const allTopics = extractAllTopics(thread)
return !availableSubtopics.some(subtopic => allTopics.includes(subtopic))
}).length === 1 ? t('thread') : t('threads')})
</span> </span>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{threads.filter(thread => !thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).map(thread => ( {threads.filter(thread => {
const allTopics = extractAllTopics(thread)
return !availableSubtopics.some(subtopic => allTopics.includes(subtopic))
}).map(thread => (
<ThreadCard <ThreadCard
key={thread.id} key={thread.id}
thread={thread} thread={thread}
@ -553,17 +590,36 @@ const DiscussionsPage = forwardRef((_, ref) => {
</div> </div>
</div> </div>
{/* Readings Section */} {/* Dynamic subtopics sections */}
<div className="space-y-3"> {availableSubtopics.map(subtopic => {
const subtopicThreads = threads.filter(thread => {
const allTopics = extractAllTopics(thread)
return allTopics.includes(subtopic)
})
if (subtopicThreads.length === 0) return null
// Special handling for 'readings' subtopic in literature
const isReadingsSubtopic = subtopic === 'readings' && selectedTopic === 'literature'
return (
<div key={subtopic} className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b"> <div className="flex items-center gap-2 pb-2 border-b">
{isReadingsSubtopic ? (
<Book className="w-5 h-5 text-primary" /> <Book className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t('Readings')}</h2> ) : (
<Hash className="w-5 h-5 text-primary" />
)}
<h2 className="text-lg font-semibold">
{subtopic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')}
</h2>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
({threads.filter(thread => thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).length} {threads.filter(thread => thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')).length === 1 ? t('thread') : t('threads')}) ({subtopicThreads.length} {subtopicThreads.length === 1 ? t('thread') : t('threads')})
</span> </span>
</div> </div>
{/* Readings-specific search and filter */} {/* Special search/filter for readings subtopic */}
{isReadingsSubtopic && (
<div className="flex gap-2 items-center p-3 bg-muted/30 rounded-lg"> <div className="flex gap-2 items-center p-3 bg-muted/30 rounded-lg">
<input <input
type="text" type="text"
@ -582,12 +638,13 @@ const DiscussionsPage = forwardRef((_, ref) => {
<option value="subject">{t('Subject')}</option> <option value="subject">{t('Subject')}</option>
</select> </select>
</div> </div>
)}
<div className="space-y-3"> <div className="space-y-3">
{threads {subtopicThreads
.filter(thread => thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings'))
.filter(thread => { .filter(thread => {
if (!searchQuery.trim()) return true // Apply search filter only for readings subtopic
if (!isReadingsSubtopic || !searchQuery.trim()) return true
const authorTag = thread.tags.find(tag => tag[0] === 'author') const authorTag = thread.tags.find(tag => tag[0] === 'author')
const subjectTag = thread.tags.find(tag => tag[0] === 'subject') const subjectTag = thread.tags.find(tag => tag[0] === 'subject')
@ -615,6 +672,8 @@ const DiscussionsPage = forwardRef((_, ref) => {
))} ))}
</div> </div>
</div> </div>
)
})}
</div> </div>
) : (viewMode === 'grouped' && selectedTopic === 'all' ? ) : (viewMode === 'grouped' && selectedTopic === 'all' ?
Object.keys(groupedThreads).length === 0 : Object.keys(groupedThreads).length === 0 :

Loading…
Cancel
Save