Browse Source

h-tagged discussions

imwald
Silberengel 5 months ago
parent
commit
584b801d82
  1. 35
      src/App.tsx
  2. 12
      src/components/DiscussionNote/index.tsx
  3. 1
      src/constants.ts
  4. 95
      src/lib/discussion-topics.ts
  5. 88
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  6. 19
      src/pages/primary/DiscussionsPage/ThreadCard.tsx
  7. 23
      src/pages/primary/DiscussionsPage/TopicFilter.tsx
  8. 109
      src/pages/primary/DiscussionsPage/index.tsx
  9. 135
      src/providers/GroupListProvider.tsx

35
src/App.tsx

@ -8,6 +8,7 @@ import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider' import { FeedProvider } from '@/providers/FeedProvider'
import { FollowListProvider } from '@/providers/FollowListProvider' import { FollowListProvider } from '@/providers/FollowListProvider'
import { GroupListProvider } from '@/providers/GroupListProvider'
import { InterestListProvider } from '@/providers/InterestListProvider' import { InterestListProvider } from '@/providers/InterestListProvider'
import { KindFilterProvider } from '@/providers/KindFilterProvider' import { KindFilterProvider } from '@/providers/KindFilterProvider'
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider' import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
@ -35,22 +36,24 @@ export default function App(): JSX.Element {
<FollowListProvider> <FollowListProvider>
<MuteListProvider> <MuteListProvider>
<InterestListProvider> <InterestListProvider>
<UserTrustProvider> <GroupListProvider>
<BookmarksProvider> <UserTrustProvider>
<FeedProvider> <BookmarksProvider>
<ReplyProvider> <FeedProvider>
<MediaUploadServiceProvider> <ReplyProvider>
<KindFilterProvider> <MediaUploadServiceProvider>
<UserPreferencesProvider> <KindFilterProvider>
<PageManager /> <UserPreferencesProvider>
<Toaster /> <PageManager />
</UserPreferencesProvider> <Toaster />
</KindFilterProvider> </UserPreferencesProvider>
</MediaUploadServiceProvider> </KindFilterProvider>
</ReplyProvider> </MediaUploadServiceProvider>
</FeedProvider> </ReplyProvider>
</BookmarksProvider> </FeedProvider>
</UserTrustProvider> </BookmarksProvider>
</UserTrustProvider>
</GroupListProvider>
</InterestListProvider> </InterestListProvider>
</MuteListProvider> </MuteListProvider>
</FollowListProvider> </FollowListProvider>

12
src/components/DiscussionNote/index.tsx

@ -1,10 +1,11 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { MessageCircle, Hash } from 'lucide-react' import { MessageCircle, Hash, Users } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/CreateThreadDialog' import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/CreateThreadDialog'
import { extractGroupInfo } from '@/lib/discussion-topics'
interface DiscussionNoteProps { interface DiscussionNoteProps {
event: Event event: Event
@ -21,6 +22,9 @@ export default function DiscussionNote({ event, className, size = 'normal' }: Di
const title = titleTag?.[1] || 'Untitled Discussion' const title = titleTag?.[1] || 'Untitled Discussion'
const topic = topicTag?.[1] || 'general' const topic = topicTag?.[1] || 'general'
// Extract group information
const groupInfo = extractGroupInfo(event, ['unknown'])
// Get topic info // Get topic info
const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topic) || { const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topic) || {
id: topic, id: topic,
@ -44,6 +48,12 @@ export default function DiscussionNote({ event, className, size = 'normal' }: Di
<topicInfo.icon className="w-3 h-3 mr-1" /> <topicInfo.icon className="w-3 h-3 mr-1" />
{topicInfo.label} {topicInfo.label}
</Badge> </Badge>
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && (
<Badge variant="outline" className="text-xs">
<Users className="w-3 h-3 mr-1" />
{groupInfo.groupDisplayName}
</Badge>
)}
<span className={cn('text-xs text-muted-foreground', isSmall && 'text-xs')}> <span className={cn('text-xs text-muted-foreground', isSmall && 'text-xs')}>
{t('Discussion')} {t('Discussion')}
</span> </span>

1
src/constants.ts

@ -132,6 +132,7 @@ export const ExtendedKind = {
BLOSSOM_SERVER_LIST: 10063, BLOSSOM_SERVER_LIST: 10063,
RELAY_REVIEW: 31987, RELAY_REVIEW: 31987,
GROUP_METADATA: 39000, GROUP_METADATA: 39000,
GROUP_LIST: 10009, // NIP-51 Group List
ZAP_REQUEST: 9734, ZAP_REQUEST: 9734,
ZAP_RECEIPT: 9735, ZAP_RECEIPT: 9735,
PUBLICATION: 30040, PUBLICATION: 30040,

95
src/lib/discussion-topics.ts

@ -225,3 +225,98 @@ export function getCategorizedTopic(
return 'general' return 'general'
} }
/**
* Extract h-tag (group ID) from event tags
*/
export function extractHTagFromEvent(event: NostrEvent): string | null {
const hTag = event.tags.find(tag => tag[0] === 'h' && tag[1])
return hTag ? hTag[1] : null
}
/**
* Parse group identifier from h-tag and relay sources
* Supports both "relay'group-id" format and bare group IDs
*/
export function parseGroupIdentifier(
hTag: string,
relaySources: string[]
): { groupId: string; groupRelay: string | null; fullIdentifier: string } {
// Check if h-tag already contains relay'group-id format
if (hTag.includes("'")) {
const [relay, groupId] = hTag.split("'", 2)
return {
groupId,
groupRelay: relay,
fullIdentifier: hTag
}
}
// For bare group IDs, use the first relay source
const groupRelay = relaySources.length > 0 ? relaySources[0] : null
const fullIdentifier = groupRelay ? `${groupRelay}'${hTag}` : hTag
return {
groupId: hTag,
groupRelay,
fullIdentifier
}
}
/**
* Check if a discussion belongs to a group
*/
export function isGroupDiscussion(event: NostrEvent): boolean {
return extractHTagFromEvent(event) !== null
}
/**
* Build display name for a group
*/
export function buildGroupDisplayName(
groupId: string,
groupRelay: string | null
): string {
if (!groupRelay) {
return groupId
}
// Extract hostname from relay URL for cleaner display
try {
const url = new URL(groupRelay)
const hostname = url.hostname
return `${hostname}'${groupId}`
} catch {
// Fallback to full relay URL if parsing fails
return `${groupRelay}'${groupId}`
}
}
/**
* Extract group information from event
*/
export function extractGroupInfo(
event: NostrEvent,
relaySources: string[]
): { groupId: string | null; groupRelay: string | null; groupDisplayName: string | null; isGroupDiscussion: boolean } {
const hTag = extractHTagFromEvent(event)
if (!hTag) {
return {
groupId: null,
groupRelay: null,
groupDisplayName: null,
isGroupDiscussion: false
}
}
const { groupId, groupRelay, fullIdentifier } = parseGroupIdentifier(hTag, relaySources)
const groupDisplayName = buildGroupDisplayName(groupId, groupRelay)
return {
groupId,
groupRelay,
groupDisplayName,
isGroupDiscussion: true
}
}

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

@ -15,6 +15,7 @@ import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useGroupList } from '@/providers/GroupListProvider'
import { TDraftEvent, TRelaySet } from '@/types' import { TDraftEvent, TRelaySet } from '@/types'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { prefixNostrAddresses } from '@/lib/nostr-address' import { prefixNostrAddresses } from '@/lib/nostr-address'
@ -92,7 +93,8 @@ export const DISCUSSION_TOPICS = [
{ id: 'travel', label: 'Travel & Adventure', icon: MapPin }, { id: 'travel', label: 'Travel & Adventure', icon: MapPin },
{ id: 'home', label: 'Home & Garden', icon: Home }, { id: 'home', label: 'Home & Garden', icon: Home },
{ id: 'pets', label: 'Pets & Animals', icon: PawPrint }, { id: 'pets', label: 'Pets & Animals', icon: PawPrint },
{ id: 'fashion', label: 'Fashion & Beauty', icon: Shirt } { id: 'fashion', label: 'Fashion & Beauty', icon: Shirt },
{ id: 'groups', label: 'Groups', icon: Users }
] ]
export default function CreateThreadDialog({ export default function CreateThreadDialog({
@ -107,13 +109,14 @@ export default function CreateThreadDialog({
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, publish, relayList } = useNostr() const { pubkey, publish, relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { userGroups } = useGroupList()
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [selectedTopic, setSelectedTopic] = useState(initialTopic) const [selectedTopic, setSelectedTopic] = useState(initialTopic)
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([]) const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
const [selectableRelays, setSelectableRelays] = useState<string[]>([]) const [selectableRelays, setSelectableRelays] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<{ title?: string; content?: string; relay?: string; author?: string; subject?: string }>({}) const [errors, setErrors] = useState<{ title?: string; content?: string; relay?: string; author?: string; subject?: string; group?: string }>({})
const [isNsfw, setIsNsfw] = useState(false) const [isNsfw, setIsNsfw] = useState(false)
const [addClientTag, setAddClientTag] = useState(true) const [addClientTag, setAddClientTag] = useState(true)
const [minPow, setMinPow] = useState(0) const [minPow, setMinPow] = useState(0)
@ -126,6 +129,10 @@ export default function CreateThreadDialog({
const [author, setAuthor] = useState('') const [author, setAuthor] = useState('')
const [subject, setSubject] = useState('') const [subject, setSubject] = useState('')
const [showReadingsPanel, setShowReadingsPanel] = useState(false) const [showReadingsPanel, setShowReadingsPanel] = useState(false)
// Group options state
const [selectedGroup, setSelectedGroup] = useState<string>('')
const [isGroupSelectorOpen, setIsGroupSelectorOpen] = useState(false)
// Create combined topics list (predefined + dynamic) with hierarchy // Create combined topics list (predefined + dynamic) with hierarchy
const allAvailableTopics = useMemo(() => { const allAvailableTopics = useMemo(() => {
@ -255,7 +262,7 @@ export default function CreateThreadDialog({
} }
const validateForm = () => { const validateForm = () => {
const newErrors: { title?: string; content?: string; relay?: string; author?: string; subject?: string } = {} const newErrors: { title?: string; content?: string; relay?: string; author?: string; subject?: string; group?: string } = {}
if (!title.trim()) { if (!title.trim()) {
newErrors.title = t('Title is required') newErrors.title = t('Title is required')
@ -283,6 +290,13 @@ export default function CreateThreadDialog({
} }
} }
// Validate group selection if groups topic is selected
if (selectedTopic === 'groups') {
if (!selectedGroup.trim()) {
newErrors.group = t('Please select a group')
}
}
setErrors(newErrors) setErrors(newErrors)
return Object.keys(newErrors).length === 0 return Object.keys(newErrors).length === 0
} }
@ -317,8 +331,13 @@ export default function CreateThreadDialog({
['-'] // Required tag for relay privacy ['-'] // Required tag for relay privacy
] ]
// Only add topic tag if it's a specific topic (not 'all' or 'general') // Add h tag for group discussions
if (selectedTopic !== 'all' && selectedTopic !== 'general') { if (selectedTopic === 'groups' && selectedGroup) {
tags.push(['h', selectedGroup])
}
// Only add topic tag if it's a specific topic (not 'all' or 'general' or 'groups')
if (selectedTopic !== 'all' && selectedTopic !== 'general' && selectedTopic !== 'groups') {
// Check if this is a dynamic subtopic // Check if this is a dynamic subtopic
const selectedDynamicTopic = dynamicTopics?.allTopics.find(dt => dt.id === selectedTopic) const selectedDynamicTopic = dynamicTopics?.allTopics.find(dt => dt.id === selectedTopic)
@ -550,6 +569,65 @@ export default function CreateThreadDialog({
</p> </p>
</div> </div>
{/* Group Selection - Only show when Groups topic is selected */}
{selectedTopic === 'groups' && (
<div className="space-y-2">
<Label htmlFor="group">{t('Select Group')}</Label>
<Popover open={isGroupSelectorOpen} onOpenChange={setIsGroupSelectorOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isGroupSelectorOpen}
className="w-full justify-between"
>
{selectedGroup ? selectedGroup : t('Select group...')}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-2 z-[10000]"
align="start"
side="bottom"
sideOffset={4}
>
<div className="max-h-60 overflow-y-auto">
{userGroups.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
{t('No groups available. Join some groups first.')}
</div>
) : (
userGroups.map((groupId) => (
<div
key={groupId}
className="flex items-center p-2 hover:bg-accent cursor-pointer rounded"
onClick={() => {
setSelectedGroup(groupId)
setIsGroupSelectorOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedGroup === groupId ? 'opacity-100' : 'opacity-0'
}`}
/>
<Users className="mr-2 h-4 w-4" />
{groupId}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
{errors.group && (
<p className="text-sm text-destructive">{errors.group}</p>
)}
<p className="text-sm text-muted-foreground">
{t('Select the group where you want to create this discussion.')}
</p>
</div>
)}
{/* Title Input */} {/* Title Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">{t('Thread Title')}</Label> <Label htmlFor="title">{t('Thread Title')}</Label>

19
src/pages/primary/DiscussionsPage/ThreadCard.tsx

@ -1,6 +1,6 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Clock, Hash } from 'lucide-react' import { Clock, Hash, Users } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -9,7 +9,7 @@ import { DISCUSSION_TOPICS } from './CreateThreadDialog'
import Username from '@/components/Username' import Username from '@/components/Username'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { extractAllTopics } from '@/lib/discussion-topics' import { extractAllTopics, extractGroupInfo } from '@/lib/discussion-topics'
interface ThreadCardProps { interface ThreadCardProps {
thread: NostrEvent thread: NostrEvent
@ -46,6 +46,9 @@ export default function ThreadCard({
icon: Hash icon: Hash
} }
// Extract group information
const groupInfo = extractGroupInfo(thread, ['unknown'])
// Get all topics from this thread // Get all topics from this thread
const allTopics = extractAllTopics(thread) const allTopics = extractAllTopics(thread)
@ -95,6 +98,12 @@ export default function ThreadCard({
<topicInfo.icon className="w-4 h-4" /> <topicInfo.icon className="w-4 h-4" />
<span className="text-xs">{topicInfo.id}</span> <span className="text-xs">{topicInfo.id}</span>
</div> </div>
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && (
<Badge variant="outline" className="text-xs">
<Users className="w-3 h-3 mr-1" />
{groupInfo.groupDisplayName}
</Badge>
)}
{allTopics.slice(0, 3).map(topic => ( {allTopics.slice(0, 3).map(topic => (
<Badge key={topic} variant="outline" className="text-xs"> <Badge key={topic} variant="outline" className="text-xs">
<Hash className="w-3 h-3 mr-1" /> <Hash className="w-3 h-3 mr-1" />
@ -142,6 +151,12 @@ export default function ThreadCard({
<topicInfo.icon className="w-3 h-3 mr-1" /> <topicInfo.icon className="w-3 h-3 mr-1" />
{topicInfo.label} {topicInfo.label}
</Badge> </Badge>
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && (
<Badge variant="outline" className="text-xs">
<Users className="w-3 h-3 mr-1" />
{groupInfo.groupDisplayName}
</Badge>
)}
{allTopics.slice(0, 3).map(topic => ( {allTopics.slice(0, 3).map(topic => (
<Badge key={topic} variant="outline" className="text-xs"> <Badge key={topic} variant="outline" className="text-xs">
<Hash className="w-3 h-3 mr-1" /> <Hash className="w-3 h-3 mr-1" />

23
src/pages/primary/DiscussionsPage/TopicFilter.tsx

@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { ChevronDown, Grid3X3 } from 'lucide-react' import { ChevronDown, Grid3X3, Users } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -53,8 +53,16 @@ export default function TopicFilter({ topics, selectedTopic, onTopicChange, thre
// Create all topics option // Create all topics option
const allTopicsOption = { id: 'all', label: t('All Topics'), icon: Grid3X3 } const allTopicsOption = { id: 'all', label: t('All Topics'), icon: Grid3X3 }
// Create groups option if there are group discussions
const hasGroupDiscussions = threads.some(thread =>
thread.tags.some(tag => tag[0] === 'h' && tag[1])
)
const groupsOption = hasGroupDiscussions ? { id: 'groups', label: t('Groups'), icon: Users } : null
const selectedTopicInfo = selectedTopic === 'all' const selectedTopicInfo = selectedTopic === 'all'
? allTopicsOption ? allTopicsOption
: selectedTopic === 'groups' && groupsOption
? groupsOption
: sortedTopics.find(topic => topic.id === selectedTopic) || sortedTopics[0] : sortedTopics.find(topic => topic.id === selectedTopic) || sortedTopics[0]
return ( return (
@ -80,6 +88,19 @@ export default function TopicFilter({ topics, selectedTopic, onTopicChange, thre
<span className="ml-auto text-primary"></span> <span className="ml-auto text-primary"></span>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
{groupsOption && (
<DropdownMenuItem
key="groups"
onClick={() => onTopicChange('groups')}
className="flex items-center gap-2"
>
<Users className="w-4 h-4" />
<span>{t('Groups')}</span>
{selectedTopic === 'groups' && (
<span className="ml-auto text-primary"></span>
)}
</DropdownMenuItem>
)}
{sortedTopics.map(topic => ( {sortedTopics.map(topic => (
<DropdownMenuItem <DropdownMenuItem
key={topic.id} key={topic.id}

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

@ -15,6 +15,7 @@ import { DISCUSSION_TOPICS } from './CreateThreadDialog'
import ThreadCard from './ThreadCard' import ThreadCard from './ThreadCard'
import CreateThreadDialog from './CreateThreadDialog' import CreateThreadDialog from './CreateThreadDialog'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { extractGroupInfo } from '@/lib/discussion-topics'
// Simple event map type // Simple event map type
type EventMapEntry = { type EventMapEntry = {
@ -29,6 +30,11 @@ type EventMapEntry = {
lastVoteTime: number lastVoteTime: number
upVotes: number upVotes: number
downVotes: number downVotes: number
// Group-related fields
groupId: string | null
groupRelay: string | null
groupDisplayName: string | null
isGroupDiscussion: boolean
} }
// Vote counting function - separate and clean // Vote counting function - separate and clean
@ -117,7 +123,12 @@ function countCommentsForThread(threadId: string, comments: NostrEvent[], thread
} }
// Topic categorization function // Topic categorization function
function getTopicFromTags(allTopics: string[], predefinedTopicIds: string[]): string { function getTopicFromTags(allTopics: string[], predefinedTopicIds: string[], isGroupDiscussion: boolean = false): string {
// If it's a group discussion, categorize as 'groups'
if (isGroupDiscussion) {
return 'groups'
}
for (const topic of allTopics) { for (const topic of allTopics) {
if (predefinedTopicIds.includes(topic)) { if (predefinedTopicIds.includes(topic)) {
return topic return topic
@ -201,6 +212,7 @@ function analyzeDynamicTopics(entries: EventMapEntry[]): {
allTopics: DynamicTopic[] allTopics: DynamicTopic[]
} { } {
const hashtagCounts = new Map<string, number>() const hashtagCounts = new Map<string, number>()
const groupCounts = new Map<string, number>()
const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id) const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id)
// Count hashtag frequency // Count hashtag frequency
@ -211,6 +223,11 @@ function analyzeDynamicTopics(entries: EventMapEntry[]): {
hashtagCounts.set(topic, (hashtagCounts.get(topic) || 0) + 1) hashtagCounts.set(topic, (hashtagCounts.get(topic) || 0) + 1)
} }
}) })
// Count group discussions
if (entry.isGroupDiscussion && entry.groupDisplayName) {
groupCounts.set(entry.groupDisplayName, (groupCounts.get(entry.groupDisplayName) || 0) + 1)
}
}) })
const mainTopics: DynamicTopic[] = [] const mainTopics: DynamicTopic[] = []
@ -233,6 +250,32 @@ function analyzeDynamicTopics(entries: EventMapEntry[]): {
} }
}) })
// Add "Groups" as a pseudo main-topic if we have group discussions
if (groupCounts.size > 0) {
const totalGroupDiscussions = Array.from(groupCounts.values()).reduce((sum, count) => sum + count, 0)
const groupsMainTopic: DynamicTopic = {
id: 'groups',
label: 'Groups',
count: totalGroupDiscussions,
isMainTopic: true,
isSubtopic: false
}
mainTopics.push(groupsMainTopic)
// Add individual groups as subtopics under "Groups"
groupCounts.forEach((count, groupDisplayName) => {
const groupSubtopic: DynamicTopic = {
id: `groups-${groupDisplayName}`,
label: groupDisplayName,
count,
isMainTopic: false,
isSubtopic: true,
parentTopic: 'groups'
}
subtopics.push(groupSubtopic)
})
}
// Sort by count (most popular first) // Sort by count (most popular first)
mainTopics.sort((a, b) => b.count - a.count) mainTopics.sort((a, b) => b.count - a.count)
subtopics.sort((a, b) => b.count - a.count) subtopics.sort((a, b) => b.count - a.count)
@ -251,7 +294,12 @@ function analyzeDynamicTopics(entries: EventMapEntry[]): {
} }
// Enhanced topic categorization with dynamic topics // Enhanced topic categorization with dynamic topics
function getEnhancedTopicFromTags(allTopics: string[], predefinedTopicIds: string[], dynamicTopics: DynamicTopic[]): string { function getEnhancedTopicFromTags(allTopics: string[], predefinedTopicIds: string[], dynamicTopics: DynamicTopic[], isGroupDiscussion: boolean = false): string {
// If it's a group discussion, categorize as 'groups'
if (isGroupDiscussion) {
return 'groups'
}
// First check predefined topics (these are main topics) // First check predefined topics (these are main topics)
for (const topic of allTopics) { for (const topic of allTopics) {
if (predefinedTopicIds.includes(topic)) { if (predefinedTopicIds.includes(topic)) {
@ -416,7 +464,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
// Categorize topic (will be updated after dynamic topics are analyzed) // Categorize topic (will be updated after dynamic topics are analyzed)
const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id)
const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds) const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds, groupInfo.isGroupDiscussion)
// Normalize topics // Normalize topics
const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag)) const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag))
@ -427,6 +475,9 @@ const DiscussionsPage = forwardRef((_, ref) => {
const eventHints = client.getEventHints(threadId) const eventHints = client.getEventHints(threadId)
const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] const relaySources = eventHints.length > 0 ? eventHints : ['unknown']
// Extract group information
const groupInfo = extractGroupInfo(thread, relaySources)
newEventMap.set(threadId, { newEventMap.set(threadId, {
event: thread, event: thread,
relaySources, relaySources,
@ -438,7 +489,12 @@ const DiscussionsPage = forwardRef((_, ref) => {
lastCommentTime: commentStats.lastCommentTime, lastCommentTime: commentStats.lastCommentTime,
lastVoteTime: voteStats.lastVoteTime, lastVoteTime: voteStats.lastVoteTime,
upVotes: voteStats.upVotes, upVotes: voteStats.upVotes,
downVotes: voteStats.downVotes downVotes: voteStats.downVotes,
// Group-related fields
groupId: groupInfo.groupId,
groupRelay: groupInfo.groupRelay,
groupDisplayName: groupInfo.groupDisplayName,
isGroupDiscussion: groupInfo.isGroupDiscussion
}) })
}) })
@ -462,7 +518,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
const updatedEventMap = new Map<string, EventMapEntry>() const updatedEventMap = new Map<string, EventMapEntry>()
newEventMap.forEach((entry, threadId) => { newEventMap.forEach((entry, threadId) => {
const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id)
const enhancedTopic = getEnhancedTopicFromTags(entry.allTopics, predefinedTopicIds, dynamicTopicsAnalysis.allTopics) const enhancedTopic = getEnhancedTopicFromTags(entry.allTopics, predefinedTopicIds, dynamicTopicsAnalysis.allTopics, entry.isGroupDiscussion)
updatedEventMap.set(threadId, { updatedEventMap.set(threadId, {
...entry, ...entry,
@ -537,8 +593,21 @@ const DiscussionsPage = forwardRef((_, ref) => {
passesTimeFilter = mostRecentActivity > timeSpanAgo passesTimeFilter = mostRecentActivity > timeSpanAgo
} }
// Filter by topic // Filter by topic (including group filtering)
const passesTopicFilter = selectedTopic === 'all' || entry.categorizedTopic === selectedTopic let passesTopicFilter = false
if (selectedTopic === 'all') {
passesTopicFilter = true
} else if (selectedTopic === 'groups') {
// Show all group discussions when "Groups" main topic is selected
passesTopicFilter = entry.isGroupDiscussion
} else if (selectedTopic.startsWith('groups-')) {
// Show specific group when group subtopic is selected
const groupDisplayName = selectedTopic.replace('groups-', '')
passesTopicFilter = entry.isGroupDiscussion && entry.groupDisplayName === groupDisplayName
} else {
// Regular topic filtering
passesTopicFilter = entry.categorizedTopic === selectedTopic
}
if (passesTimeFilter && passesTopicFilter) { if (passesTimeFilter && passesTopicFilter) {
filteredMap.set(entry.event.id, entry) filteredMap.set(entry.event.id, entry)
@ -746,27 +815,35 @@ const DiscussionsPage = forwardRef((_, ref) => {
const threadId = publishedEvent.id const threadId = publishedEvent.id
const tTagsRaw = publishedEvent.tags.filter((tag: string[]) => tag[0] === 't' && tag[1]).map((tag: string[]) => tag[1].toLowerCase()) const tTagsRaw = publishedEvent.tags.filter((tag: string[]) => tag[0] === 't' && tag[1]).map((tag: string[]) => tag[1].toLowerCase())
const hashtagsRaw = (publishedEvent.content.match(/#\w+/g) || []).map((tag: string) => tag.slice(1).toLowerCase()) const hashtagsRaw = (publishedEvent.content.match(/#\w+/g) || []).map((tag: string) => tag.slice(1).toLowerCase())
const allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])] const allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])]
const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id)
const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds)
const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag)) const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag))
const hashtags = hashtagsRaw.map((tag: string) => normalizeTopic(tag)) const hashtags = hashtagsRaw.map((tag: string) => normalizeTopic(tag))
const allTopics = [...new Set([...tTags, ...hashtags])] const allTopics = [...new Set([...tTags, ...hashtags])]
const eventHints = client.getEventHints(threadId) const eventHints = client.getEventHints(threadId)
const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] const relaySources = eventHints.length > 0 ? eventHints : ['unknown']
// Extract group information
const groupInfo = extractGroupInfo(publishedEvent, relaySources)
const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds, groupInfo.isGroupDiscussion)
const newEntry: EventMapEntry = { const newEntry: EventMapEntry = {
event: publishedEvent, event: publishedEvent,
relaySources, relaySources,
tTags, tTags,
hashtags, hashtags,
allTopics, allTopics,
categorizedTopic, categorizedTopic,
commentCount: 0, commentCount: 0,
lastCommentTime: 0, lastCommentTime: 0,
lastVoteTime: 0, lastVoteTime: 0,
upVotes: 0, upVotes: 0,
downVotes: 0 downVotes: 0,
// Group-related fields
groupId: groupInfo.groupId,
groupRelay: groupInfo.groupRelay,
groupDisplayName: groupInfo.groupDisplayName,
isGroupDiscussion: groupInfo.isGroupDiscussion
} }
setAllEventMap(prev => new Map(prev).set(threadId, newEntry)) setAllEventMap(prev => new Map(prev).set(threadId, newEntry))

135
src/providers/GroupListProvider.tsx

@ -0,0 +1,135 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { ExtendedKind } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import logger from '@/lib/logger'
interface GroupListContextType {
userGroups: string[]
isUserInGroup: (groupId: string) => boolean
refreshGroupList: () => Promise<void>
isLoading: boolean
}
const GroupListContext = createContext<GroupListContextType | undefined>(undefined)
export const useGroupList = () => {
const context = useContext(GroupListContext)
if (context === undefined) {
throw new Error('useGroupList must be used within a GroupListProvider')
}
return context
}
export function GroupListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const { pubkey: accountPubkey, publish, updateGroupListEvent } = useNostr()
const { favoriteRelays } = useFavoriteRelays()
const [userGroups, setUserGroups] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false)
// Build comprehensive relay list for fetching group list
const buildComprehensiveRelayList = useCallback(async () => {
const myRelayList = accountPubkey ? await client.fetchRelayList(accountPubkey) : { write: [], read: [] }
const allRelays = [
...(myRelayList.read || []), // User's inboxes (kind 10002)
...(myRelayList.write || []), // User's outboxes (kind 10002)
...(favoriteRelays || []), // User's favorite relays (kind 10012)
...BIG_RELAY_URLS, // Big relays
...FAST_READ_RELAY_URLS // Fast read relays
]
const normalizedRelays = allRelays
.map(url => normalizeUrl(url))
.filter((url): url is string => !!url)
return Array.from(new Set(normalizedRelays))
}, [accountPubkey, favoriteRelays])
// Fetch user's group list (kind 10009)
const fetchGroupList = useCallback(async () => {
if (!accountPubkey) {
setUserGroups([])
return
}
try {
setIsLoading(true)
logger.debug('[GroupListProvider] Fetching group list for user:', accountPubkey.substring(0, 8))
// Get comprehensive relay list
const allRelays = await buildComprehensiveRelayList()
// Fetch group list event (kind 10009)
const groupListEvents = await client.fetchEvents(allRelays, [
{
kinds: [ExtendedKind.GROUP_LIST],
authors: [accountPubkey],
limit: 1
}
])
if (groupListEvents.length > 0) {
const groupListEvent = groupListEvents[0]
logger.debug('[GroupListProvider] Found group list event:', groupListEvent.id.substring(0, 8))
// Extract groups from a-tags (group coordinates)
const groups: string[] = []
groupListEvent.tags.forEach(tag => {
if (tag[0] === 'a' && tag[1]) {
// Parse group coordinate: kind:pubkey:group-id
const coordinate = tag[1]
const parts = coordinate.split(':')
if (parts.length >= 3) {
const groupId = parts[2]
groups.push(groupId)
}
}
})
setUserGroups(groups)
logger.debug('[GroupListProvider] Extracted groups:', groups)
} else {
setUserGroups([])
logger.debug('[GroupListProvider] No group list found')
}
} catch (error) {
logger.error('[GroupListProvider] Error fetching group list:', error)
setUserGroups([])
} finally {
setIsLoading(false)
}
}, [accountPubkey, buildComprehensiveRelayList])
// Check if user is in a specific group
const isUserInGroup = useCallback((groupId: string): boolean => {
return userGroups.includes(groupId)
}, [userGroups])
// Refresh group list
const refreshGroupList = useCallback(async () => {
await fetchGroupList()
}, [fetchGroupList])
// Load group list on mount and when account changes
useEffect(() => {
fetchGroupList()
}, [fetchGroupList])
const contextValue = useMemo(() => ({
userGroups,
isUserInGroup,
refreshGroupList,
isLoading
}), [userGroups, isUserInGroup, refreshGroupList, isLoading])
return (
<GroupListContext.Provider value={contextValue}>
{children}
</GroupListContext.Provider>
)
}
Loading…
Cancel
Save