|
|
|
|
@ -22,11 +22,116 @@ import storage from '@/services/local-storage.service'
@@ -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) => {
@@ -57,8 +162,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
|
|
|
|
|
const [groupedEvents, setGroupedEvents] = useState<Record<string, NostrEvent[]>>({}) |
|
|
|
|
const [searchQuery, setSearchQuery] = useState('') |
|
|
|
|
|
|
|
|
|
// Topic analysis for dynamic subtopics
|
|
|
|
|
const [topicAnalysis, setTopicAnalysis] = useState<ReturnType<typeof analyzeThreadTopics>>(new Map()) |
|
|
|
|
// Available subtopics for the selected topic
|
|
|
|
|
const [availableSubtopics, setAvailableSubtopics] = useState<string[]>([]) |
|
|
|
|
|
|
|
|
|
// State for all available relays
|
|
|
|
|
@ -175,11 +279,19 @@ const DiscussionsPage = forwardRef((_, ref) => {
@@ -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) => {
@@ -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) => {
@@ -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) => {
@@ -608,7 +713,12 @@ const DiscussionsPage = forwardRef((_, ref) => {
|
|
|
|
|
<div className="space-y-6"> |
|
|
|
|
{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 ( |
|
|
|
|
<div key={topicId} className="space-y-3"> |
|
|
|
|
@ -624,7 +734,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
@@ -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 ( |
|
|
|
|
<ThreadCard |
|
|
|
|
@ -648,7 +758,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
@@ -648,7 +758,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 ( |
|
|
|
|
<ThreadCard |
|
|
|
|
|