4 changed files with 394 additions and 96 deletions
@ -0,0 +1,160 @@
@@ -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' |
||||
} |
||||
|
||||
@ -0,0 +1,64 @@
@@ -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> |
||||
) |
||||
} |
||||
|
||||
Loading…
Reference in new issue