diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx index 7e13d82..b4c19b4 100644 --- a/src/pages/primary/DiscussionsPage/index.tsx +++ b/src/pages/primary/DiscussionsPage/index.tsx @@ -22,11 +22,116 @@ import storage from '@/services/local-storage.service' import { useSecondaryPage } from '@/PageManager' import { toNote } from '@/lib/link' import { kinds } from 'nostr-tools' -import { - analyzeThreadTopics, - getCategorizedTopic, - getDynamicSubtopics -} from '@/lib/discussion-topics' + +// Normalize subtopic hashtags using linguistic rules to group similar variations +function normalizeSubtopic(tag: string): string { + let normalized = tag.toLowerCase().trim() + + // Don't normalize very short words (2 chars or less) + if (normalized.length <= 2) { + return normalized + } + + // Handle common suffixes to find root forms + + // Remove trailing 's' for plurals (but not if word ends in 'ss') + if (normalized.endsWith('s') && !normalized.endsWith('ss')) { + // Special cases for words ending in 'ies' -> 'y' (e.g., stories -> story) + if (normalized.endsWith('ies') && normalized.length > 4) { + return normalized.slice(0, -3) + 'y' + } + // Special cases for words ending in 'es' (e.g., churches -> church, but not always) + if (normalized.endsWith('ches') || normalized.endsWith('shes') || normalized.endsWith('xes') || + normalized.endsWith('zes') || normalized.endsWith('ses')) { + return normalized.slice(0, -2) + } + // Regular plural: just remove 's' + return normalized.slice(0, -1) + } + + // Handle -ing forms (e.g., reading -> read, cooking -> cook) + if (normalized.endsWith('ing') && normalized.length > 5) { + const root = normalized.slice(0, -3) + // Handle doubled consonants (e.g., running -> run, shopping -> shop) + if (root.length >= 2 && root[root.length - 1] === root[root.length - 2]) { + return root.slice(0, -1) + } + return root + } + + // Handle -ed forms (e.g., deleted -> delete) + if (normalized.endsWith('ed') && normalized.length > 4) { + const root = normalized.slice(0, -2) + // Handle doubled consonants + if (root.length >= 2 && root[root.length - 1] === root[root.length - 2]) { + return root.slice(0, -1) + } + return root + } + + // Handle -er forms (e.g., developer -> develop, but not 'user' -> 'us') + if (normalized.endsWith('er') && normalized.length > 4 && !normalized.endsWith('eer')) { + return normalized.slice(0, -2) + } + + // Handle -ly adverbs (e.g., quickly -> quick) + if (normalized.endsWith('ly') && normalized.length > 4) { + return normalized.slice(0, -2) + } + + // Handle -y to -ies (e.g., philosophy/philosophical, economy/economics) + // Already handled by the 'ies' -> 'y' rule above + + // Handle -ism, -ist, -ian variations (e.g., Buddhism/Buddhist, Christian/Christianity) + if (normalized.endsWith('ism') && normalized.length > 5) { + return normalized.slice(0, -3) + } + if (normalized.endsWith('ist') && normalized.length > 5) { + return normalized.slice(0, -3) + } + if (normalized.endsWith('ity') && normalized.length > 5) { + return normalized.slice(0, -3) + } + if (normalized.endsWith('ian') && normalized.length > 5) { + return normalized.slice(0, -3) + } + if (normalized.endsWith('ians') && normalized.length > 6) { + return normalized.slice(0, -4) + } + + return normalized +} + +// Function to determine topic based on actual t-tags and hashtags +function getTopicFromTags(allTopics: string[], availableTopicIds: string[]): string { + // Normalize topics to lowercase for case-insensitive matching + const normalizedTopics = allTopics.map(t => t.toLowerCase()) + const normalizedTopicIds = availableTopicIds.map(t => t.toLowerCase()) + + // Check if any of the event's topics match the available topic IDs (case-insensitive) + for (let i = 0; i < normalizedTopics.length; i++) { + const index = normalizedTopicIds.indexOf(normalizedTopics[i]) + if (index !== -1) { + return availableTopicIds[index] // Return the original case from availableTopicIds + } + } + + // If no specific topic matches, categorize as 'general' + return 'general' +} + +// Function to get dynamic subtopics from event topics +function getSubtopicsFromTopics(topics: string[], limit: number = 3): string[] { + // Get the main topic IDs from DISCUSSION_TOPICS + const mainTopicIds = DISCUSSION_TOPICS.map(topic => topic.id) + + // Filter out main topic IDs and get unique subtopics + const subtopics = topics.filter(topic => !mainTopicIds.includes(topic)) + const uniqueSubtopics = [...new Set(subtopics)] + + // Return the most common subtopics, limited by the limit + return uniqueSubtopics.slice(0, limit) +} // Simple event map type type EventMapEntry = { @@ -57,8 +162,7 @@ const DiscussionsPage = forwardRef((_, ref) => { const [groupedEvents, setGroupedEvents] = useState>({}) const [searchQuery, setSearchQuery] = useState('') - // Topic analysis for dynamic subtopics - const [topicAnalysis, setTopicAnalysis] = useState>(new Map()) + // Available subtopics for the selected topic const [availableSubtopics, setAvailableSubtopics] = useState([]) // State for all available relays @@ -175,11 +279,19 @@ const DiscussionsPage = forwardRef((_, ref) => { return } - // Extract topics - const tTags = event.tags.filter(tag => tag[0] === 't' && tag[1]).map(tag => tag[1]) - const hashtags = (event.content.match(/#\w+/g) || []).map(tag => tag.slice(1)) + // Extract topics - normalize subtopics but keep originals for topic detection + const tTagsRaw = event.tags.filter(tag => tag[0] === 't' && tag[1]).map(tag => tag[1].toLowerCase()) + // Match hashtags with letters, numbers, hyphens, and underscores + const hashtagsRaw = (event.content.match(/#[\w-]+/g) || []).map(tag => tag.slice(1).toLowerCase()) + const allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])] + + // Determine the main topic from raw tags + const categorizedTopic = getTopicFromTags(allTopicsRaw, availableTopicIds) + + // Normalize subtopics for grouping (but not main topic IDs) + const tTags = tTagsRaw.map(tag => normalizeSubtopic(tag)) + const hashtags = hashtagsRaw.map(tag => normalizeSubtopic(tag)) const allTopics = [...new Set([...tTags, ...hashtags])] - const categorizedTopic = getCategorizedTopic(event, availableTopicIds) finalEventMap.set(eventId, { event, @@ -332,21 +444,14 @@ const DiscussionsPage = forwardRef((_, ref) => { filterAndSortEvents() }, [filterAndSortEvents]) - // Analyze topics whenever event map changes - useEffect(() => { - const events = Array.from(eventMap.values()).map(entry => entry.event) - if (events.length > 0) { - const analysis = analyzeThreadTopics(events, availableTopicIds) - setTopicAnalysis(analysis) - } else { - setTopicAnalysis(new Map()) - } - }, [eventMap, availableTopicIds]) // Update available subtopics when topic analysis or selected topic changes useEffect(() => { if (selectedTopic && selectedTopic !== 'all') { - const subtopics = getDynamicSubtopics(topicAnalysis.get(selectedTopic), 3) + // Get all topics from events in this topic + const topicEvents = Array.from(eventMap.values()).filter(entry => entry.categorizedTopic === selectedTopic) + const allTopics = topicEvents.flatMap(entry => entry.allTopics) + const subtopics = getSubtopicsFromTopics(allTopics, 3) // Special case: Always include 'readings' as a subtopic for 'literature' if (selectedTopic === 'literature' && !subtopics.includes('readings')) { @@ -357,7 +462,7 @@ const DiscussionsPage = forwardRef((_, ref) => { } else { setAvailableSubtopics([]) } - }, [topicAnalysis, selectedTopic]) + }, [eventMap, selectedTopic]) const handleCreateThread = () => { setShowCreateThread(true) @@ -608,7 +713,12 @@ const DiscussionsPage = forwardRef((_, ref) => {
{Object.entries(groupedEvents).map(([topicId, topicEvents]) => { const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topicId) - if (!topicInfo || topicEvents.length === 0) return null + // Skip if no events, but don't skip if topicInfo is missing (shouldn't happen with proper data) + if (topicEvents.length === 0) return null + if (!topicInfo) { + console.warn(`Topic info not found for: ${topicId}`) + return null + } return (
@@ -624,7 +734,7 @@ const DiscussionsPage = forwardRef((_, ref) => { const entry = eventMap.get(event.id) const threadSubtopics = entry?.categorizedTopic === 'literature' ? ['readings'] - : getDynamicSubtopics(topicAnalysis.get(entry?.categorizedTopic || 'general'), 3) + : getSubtopicsFromTopics(entry?.allTopics || [], 3) return ( { const entry = eventMap.get(event.id) const threadSubtopics = entry?.categorizedTopic === 'literature' ? ['readings'] - : getDynamicSubtopics(topicAnalysis.get(entry?.categorizedTopic || 'general'), 3) + : getSubtopicsFromTopics(entry?.allTopics || [], 3) return (