From af1b686c53edad17f0b61254f3ae47888aff01b9 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 10 Oct 2025 22:37:27 +0200 Subject: [PATCH] dynamic subtopics --- src/lib/discussion-topics.ts | 160 ++++++++++++ .../DiscussionsPage/CreateThreadDialog.tsx | 19 +- .../DiscussionsPage/SubtopicFilter.tsx | 64 +++++ src/pages/primary/DiscussionsPage/index.tsx | 247 +++++++++++------- 4 files changed, 394 insertions(+), 96 deletions(-) create mode 100644 src/lib/discussion-topics.ts create mode 100644 src/pages/primary/DiscussionsPage/SubtopicFilter.tsx diff --git a/src/lib/discussion-topics.ts b/src/lib/discussion-topics.ts new file mode 100644 index 0000000..2d9cc06 --- /dev/null +++ b/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> // subtopic -> set of npubs + threads: NostrEvent[] +} + +export function analyzeThreadTopics( + threads: NostrEvent[], + availableTopicIds: string[] +): Map { + const topicMap = new Map() + + 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' +} + diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index 52db94b..a22baff 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -15,6 +15,7 @@ import { TDraftEvent } from '@/types' import { prefixNostrAddresses } from '@/lib/nostr-address' import { showPublishingError } from '@/lib/publishing-feedback' import dayjs from 'dayjs' +import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' // Utility functions for thread creation function extractImagesFromContent(content: string): string[] { @@ -145,16 +146,30 @@ export default function CreateThreadDialog({ // Extract images from processed content const images = extractImagesFromContent(processedContent) + // Extract hashtags from content + const hashtags = extractHashtagsFromContent(processedContent) + // Build tags array const tags = [ ['title', title.trim()], - ['t', selectedTopic], + ['t', normalizeTopic(selectedTopic)], ['-'] // 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 if (isReadingGroup) { - tags.push(['t', 'readings']) + // Only add if not already added from hashtags + if (!uniqueHashtags.includes('readings')) { + tags.push(['t', 'readings']) + } tags.push(['author', author.trim()]) tags.push(['subject', subject.trim()]) } diff --git a/src/pages/primary/DiscussionsPage/SubtopicFilter.tsx b/src/pages/primary/DiscussionsPage/SubtopicFilter.tsx new file mode 100644 index 0000000..05c5ad5 --- /dev/null +++ b/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 ( +
+ {t('Filter by')}: + onSubtopicChange(null)} + > + {t('All')} + + {subtopics.map(subtopic => ( + onSubtopicChange(subtopic)} + > + {formatSubtopicLabel(subtopic)} + {selectedSubtopic === subtopic && ( + + )} + + ))} +
+ ) +} + diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx index 9fe2724..56775d3 100644 --- a/src/pages/primary/DiscussionsPage/index.tsx +++ b/src/pages/primary/DiscussionsPage/index.tsx @@ -7,18 +7,25 @@ import { useNostr } from '@/providers/NostrProvider' import { forwardRef, useEffect, useState, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' 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 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 { NostrEvent } from 'nostr-tools' import client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import { useSecondaryPage } from '@/PageManager' import { toNote } from '@/lib/link' import { kinds } from 'nostr-tools' +import { + analyzeThreadTopics, + getCategorizedTopic, + getDynamicSubtopics, + extractAllTopics +} from '@/lib/discussion-topics' const DiscussionsPage = forwardRef((_, ref) => { const { t } = useTranslation() @@ -41,6 +48,10 @@ const DiscussionsPage = forwardRef((_, ref) => { // Search and filter state for readings const [searchQuery, setSearchQuery] = useState('') const [filterBy, setFilterBy] = useState<'author' | 'subject' | 'all'>('all') + + // Topic analysis for dynamic subtopics + const [topicAnalysis, setTopicAnalysis] = useState>(new Map()) + const [availableSubtopics, setAvailableSubtopics] = useState([]) // Use DEFAULT_FAVORITE_RELAYS for logged-out users, or user's favorite relays for logged-in users const availableRelays = useMemo(() => @@ -156,6 +167,26 @@ const DiscussionsPage = forwardRef((_, ref) => { 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(() => { // Only wait for stats for vote-based sorting if ((selectedSort === 'top' || selectedSort === 'controversial') && !statsLoaded) { @@ -213,30 +244,20 @@ const DiscussionsPage = forwardRef((_, ref) => { const filterThreadsByTopic = useCallback(() => { const categorizedThreads = allThreads.map(thread => { - // Find all 't' tags in the thread - const topicTags = thread.tags.filter(tag => tag[0] === 't' && tag[1]) - - // Find the first matching topic from our available topics - let matchedTopic = 'general' // Default to general - let isReadingGroup = false + // Use new function to get categorized topic (considers both hashtags and t-tags) + const matchedTopic = getCategorizedTopic(thread, availableTopicIds) - for (const topicTag of topicTags) { - if (availableTopicIds.includes(topicTag[1])) { - matchedTopic = topicTag[1] - break // Use the first match found - } - } + // Get all topics (hashtags + t-tags) for this thread + const allTopics = extractAllTopics(thread) - // 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 - } + // Check if this is a reading group thread (special subtopic for literature) + const isReadingGroup = allTopics.includes('readings') return { ...thread, _categorizedTopic: matchedTopic, - _isReadingGroup: isReadingGroup + _isReadingGroup: isReadingGroup, + _allTopics: allTopics } }) @@ -244,27 +265,24 @@ const DiscussionsPage = forwardRef((_, ref) => { let threadsForTopic = selectedTopic === 'all' ? categorizedThreads.map(thread => { // Remove the temporary categorization property but keep relay source - const { _categorizedTopic, _isReadingGroup, ...cleanThread } = thread + const { _categorizedTopic, _isReadingGroup, _allTopics, ...cleanThread } = thread return cleanThread }) : categorizedThreads .filter(thread => { if (thread._categorizedTopic !== selectedTopic) return false - // Handle subtopic filtering for literature - if (selectedTopic === 'literature' && selectedSubtopic) { - if (selectedSubtopic === 'readings') { - return thread._isReadingGroup - } else if (selectedSubtopic === 'general') { - return !thread._isReadingGroup - } + // Handle subtopic filtering + if (selectedSubtopic) { + // Check if thread matches the selected subtopic + return thread._allTopics.includes(selectedSubtopic) } return true }) .map(thread => { // Remove the temporary categorization property but keep relay source - const { _categorizedTopic, _isReadingGroup, ...cleanThread } = thread + const { _categorizedTopic, _isReadingGroup, _allTopics, ...cleanThread } = thread return cleanThread }) @@ -357,7 +375,7 @@ const DiscussionsPage = forwardRef((_, ref) => { groups[topic] = [] } // Remove the temporary categorization property but keep relay source - const { _categorizedTopic, ...cleanThread } = thread + const { _categorizedTopic, _isReadingGroup, _allTopics, ...cleanThread } = thread groups[topic].push(cleanThread) return groups }, {} as Record) @@ -525,23 +543,42 @@ const DiscussionsPage = forwardRef((_, ref) => { + {/* Subtopic filter */} + {selectedTopic !== 'all' && availableSubtopics.length > 0 && ( + + )} + {loading ? (
{t('Loading threads...')}
- ) : selectedTopic === 'literature' ? ( + ) : selectedTopic !== 'all' && availableSubtopics.length > 0 && !selectedSubtopic ? (
- {/* General Literature and Arts Section */} + {/* General section for the main topic (without subtopics) */}
-

{t('General Topics')}

+

{t('General')}

- ({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')})
- {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 => ( {
- {/* Readings Section */} -
-
- -

{t('Readings')}

- - ({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')}) - -
+ {/* Dynamic subtopics sections */} + {availableSubtopics.map(subtopic => { + const subtopicThreads = threads.filter(thread => { + const allTopics = extractAllTopics(thread) + return allTopics.includes(subtopic) + }) - {/* Readings-specific search and filter */} -
- setSearchQuery(e.target.value)} - placeholder={t('Search by author or book...')} - className="px-3 h-10 rounded border bg-background text-sm w-48" - /> - -
+ if (subtopicThreads.length === 0) return null -
- {threads - .filter(thread => thread.tags.find(tag => tag[0] === 't' && tag[1] === 'readings')) - .filter(thread => { - if (!searchQuery.trim()) return true - - const authorTag = thread.tags.find(tag => tag[0] === 'author') - const subjectTag = thread.tags.find(tag => tag[0] === 'subject') - - if (filterBy === 'author' && authorTag) { - return authorTag[1].toLowerCase().includes(searchQuery.toLowerCase()) - } else if (filterBy === 'subject' && subjectTag) { - return subjectTag[1].toLowerCase().includes(searchQuery.toLowerCase()) - } else if (filterBy === 'all') { - const authorMatch = authorTag && authorTag[1].toLowerCase().includes(searchQuery.toLowerCase()) - const subjectMatch = subjectTag && subjectTag[1].toLowerCase().includes(searchQuery.toLowerCase()) - return authorMatch || subjectMatch - } - - return false - }) - .map(thread => ( - { - push(toNote(thread)) - }} - /> - ))} -
-
+ // Special handling for 'readings' subtopic in literature + const isReadingsSubtopic = subtopic === 'readings' && selectedTopic === 'literature' + + return ( +
+
+ {isReadingsSubtopic ? ( + + ) : ( + + )} +

+ {subtopic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} +

+ + ({subtopicThreads.length} {subtopicThreads.length === 1 ? t('thread') : t('threads')}) + +
+ + {/* Special search/filter for readings subtopic */} + {isReadingsSubtopic && ( +
+ setSearchQuery(e.target.value)} + placeholder={t('Search by author or book...')} + className="px-3 h-10 rounded border bg-background text-sm w-48" + /> + +
+ )} + +
+ {subtopicThreads + .filter(thread => { + // Apply search filter only for readings subtopic + if (!isReadingsSubtopic || !searchQuery.trim()) return true + + const authorTag = thread.tags.find(tag => tag[0] === 'author') + const subjectTag = thread.tags.find(tag => tag[0] === 'subject') + + if (filterBy === 'author' && authorTag) { + return authorTag[1].toLowerCase().includes(searchQuery.toLowerCase()) + } else if (filterBy === 'subject' && subjectTag) { + return subjectTag[1].toLowerCase().includes(searchQuery.toLowerCase()) + } else if (filterBy === 'all') { + const authorMatch = authorTag && authorTag[1].toLowerCase().includes(searchQuery.toLowerCase()) + const subjectMatch = subjectTag && subjectTag[1].toLowerCase().includes(searchQuery.toLowerCase()) + return authorMatch || subjectMatch + } + + return false + }) + .map(thread => ( + { + push(toNote(thread)) + }} + /> + ))} +
+
+ ) + })}
) : (viewMode === 'grouped' && selectedTopic === 'all' ? Object.keys(groupedThreads).length === 0 :