diff --git a/src/App.tsx b/src/App.tsx index 2c8de70..56b6059 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FeedProvider } from '@/providers/FeedProvider' import { FollowListProvider } from '@/providers/FollowListProvider' +import { GroupListProvider } from '@/providers/GroupListProvider' import { InterestListProvider } from '@/providers/InterestListProvider' import { KindFilterProvider } from '@/providers/KindFilterProvider' import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider' @@ -35,22 +36,24 @@ export default function App(): JSX.Element { - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/src/components/DiscussionNote/index.tsx b/src/components/DiscussionNote/index.tsx index 54d2b54..c75bef2 100644 --- a/src/components/DiscussionNote/index.tsx +++ b/src/components/DiscussionNote/index.tsx @@ -1,10 +1,11 @@ import { Badge } from '@/components/ui/badge' 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 { cn } from '@/lib/utils' import { useTranslation } from 'react-i18next' import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/CreateThreadDialog' +import { extractGroupInfo } from '@/lib/discussion-topics' interface DiscussionNoteProps { event: Event @@ -21,6 +22,9 @@ export default function DiscussionNote({ event, className, size = 'normal' }: Di const title = titleTag?.[1] || 'Untitled Discussion' const topic = topicTag?.[1] || 'general' + // Extract group information + const groupInfo = extractGroupInfo(event, ['unknown']) + // Get topic info const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topic) || { id: topic, @@ -44,6 +48,12 @@ export default function DiscussionNote({ event, className, size = 'normal' }: Di {topicInfo.label} + {groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( + + + {groupInfo.groupDisplayName} + + )} {t('Discussion')} diff --git a/src/constants.ts b/src/constants.ts index a800297..496b079 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -132,6 +132,7 @@ export const ExtendedKind = { BLOSSOM_SERVER_LIST: 10063, RELAY_REVIEW: 31987, GROUP_METADATA: 39000, + GROUP_LIST: 10009, // NIP-51 Group List ZAP_REQUEST: 9734, ZAP_RECEIPT: 9735, PUBLICATION: 30040, diff --git a/src/lib/discussion-topics.ts b/src/lib/discussion-topics.ts index 7632764..9cabec9 100644 --- a/src/lib/discussion-topics.ts +++ b/src/lib/discussion-topics.ts @@ -225,3 +225,98 @@ export function getCategorizedTopic( 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 + } +} + diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index 776ab03..a1be204 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -15,6 +15,7 @@ import { useState, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useGroupList } from '@/providers/GroupListProvider' import { TDraftEvent, TRelaySet } from '@/types' import { NostrEvent } from 'nostr-tools' import { prefixNostrAddresses } from '@/lib/nostr-address' @@ -92,7 +93,8 @@ export const DISCUSSION_TOPICS = [ { id: 'travel', label: 'Travel & Adventure', icon: MapPin }, { id: 'home', label: 'Home & Garden', icon: Home }, { 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({ @@ -107,13 +109,14 @@ export default function CreateThreadDialog({ const { t } = useTranslation() const { pubkey, publish, relayList } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { userGroups } = useGroupList() const [title, setTitle] = useState('') const [content, setContent] = useState('') const [selectedTopic, setSelectedTopic] = useState(initialTopic) const [selectedRelayUrls, setSelectedRelayUrls] = useState([]) const [selectableRelays, setSelectableRelays] = useState([]) 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 [addClientTag, setAddClientTag] = useState(true) const [minPow, setMinPow] = useState(0) @@ -126,6 +129,10 @@ export default function CreateThreadDialog({ const [author, setAuthor] = useState('') const [subject, setSubject] = useState('') const [showReadingsPanel, setShowReadingsPanel] = useState(false) + + // Group options state + const [selectedGroup, setSelectedGroup] = useState('') + const [isGroupSelectorOpen, setIsGroupSelectorOpen] = useState(false) // Create combined topics list (predefined + dynamic) with hierarchy const allAvailableTopics = useMemo(() => { @@ -255,7 +262,7 @@ export default function CreateThreadDialog({ } 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()) { 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) return Object.keys(newErrors).length === 0 } @@ -317,8 +331,13 @@ export default function CreateThreadDialog({ ['-'] // Required tag for relay privacy ] - // Only add topic tag if it's a specific topic (not 'all' or 'general') - if (selectedTopic !== 'all' && selectedTopic !== 'general') { + // Add h tag for group discussions + 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 const selectedDynamicTopic = dynamicTopics?.allTopics.find(dt => dt.id === selectedTopic) @@ -550,6 +569,65 @@ export default function CreateThreadDialog({

+ {/* Group Selection - Only show when Groups topic is selected */} + {selectedTopic === 'groups' && ( +
+ + + + + + +
+ {userGroups.length === 0 ? ( +
+ {t('No groups available. Join some groups first.')} +
+ ) : ( + userGroups.map((groupId) => ( +
{ + setSelectedGroup(groupId) + setIsGroupSelectorOpen(false) + }} + > + + + {groupId} +
+ )) + )} +
+
+
+ {errors.group && ( +

{errors.group}

+ )} +

+ {t('Select the group where you want to create this discussion.')} +

+
+ )} + {/* Title Input */}
diff --git a/src/pages/primary/DiscussionsPage/ThreadCard.tsx b/src/pages/primary/DiscussionsPage/ThreadCard.tsx index 74ec5ed..0c6a689 100644 --- a/src/pages/primary/DiscussionsPage/ThreadCard.tsx +++ b/src/pages/primary/DiscussionsPage/ThreadCard.tsx @@ -1,6 +1,6 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card' 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 { formatDistanceToNow } from 'date-fns' import { useTranslation } from 'react-i18next' @@ -9,7 +9,7 @@ import { DISCUSSION_TOPICS } from './CreateThreadDialog' import Username from '@/components/Username' import UserAvatar from '@/components/UserAvatar' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { extractAllTopics } from '@/lib/discussion-topics' +import { extractAllTopics, extractGroupInfo } from '@/lib/discussion-topics' interface ThreadCardProps { thread: NostrEvent @@ -46,6 +46,9 @@ export default function ThreadCard({ icon: Hash } + // Extract group information + const groupInfo = extractGroupInfo(thread, ['unknown']) + // Get all topics from this thread const allTopics = extractAllTopics(thread) @@ -95,6 +98,12 @@ export default function ThreadCard({ {topicInfo.id}
+ {groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( + + + {groupInfo.groupDisplayName} + + )} {allTopics.slice(0, 3).map(topic => ( @@ -142,6 +151,12 @@ export default function ThreadCard({ {topicInfo.label} + {groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( + + + {groupInfo.groupDisplayName} + + )} {allTopics.slice(0, 3).map(topic => ( diff --git a/src/pages/primary/DiscussionsPage/TopicFilter.tsx b/src/pages/primary/DiscussionsPage/TopicFilter.tsx index 6bd98ea..ae68261 100644 --- a/src/pages/primary/DiscussionsPage/TopicFilter.tsx +++ b/src/pages/primary/DiscussionsPage/TopicFilter.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui/button' 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 { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -53,8 +53,16 @@ export default function TopicFilter({ topics, selectedTopic, onTopicChange, thre // Create all topics option 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' ? allTopicsOption + : selectedTopic === 'groups' && groupsOption + ? groupsOption : sortedTopics.find(topic => topic.id === selectedTopic) || sortedTopics[0] return ( @@ -80,6 +88,19 @@ export default function TopicFilter({ topics, selectedTopic, onTopicChange, thre )} + {groupsOption && ( + onTopicChange('groups')} + className="flex items-center gap-2" + > + + {t('Groups')} + {selectedTopic === 'groups' && ( + + )} + + )} {sortedTopics.map(topic => ( () + const groupCounts = new Map() const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id) // Count hashtag frequency @@ -211,6 +223,11 @@ function analyzeDynamicTopics(entries: EventMapEntry[]): { 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[] = [] @@ -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) mainTopics.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 -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) for (const topic of allTopics) { if (predefinedTopicIds.includes(topic)) { @@ -416,7 +464,7 @@ const DiscussionsPage = forwardRef((_, ref) => { // Categorize topic (will be updated after dynamic topics are analyzed) const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) - const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds) + const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds, groupInfo.isGroupDiscussion) // Normalize topics const tTags = tTagsRaw.map((tag: string) => normalizeTopic(tag)) @@ -427,6 +475,9 @@ const DiscussionsPage = forwardRef((_, ref) => { const eventHints = client.getEventHints(threadId) const relaySources = eventHints.length > 0 ? eventHints : ['unknown'] + // Extract group information + const groupInfo = extractGroupInfo(thread, relaySources) + newEventMap.set(threadId, { event: thread, relaySources, @@ -438,7 +489,12 @@ const DiscussionsPage = forwardRef((_, ref) => { lastCommentTime: commentStats.lastCommentTime, lastVoteTime: voteStats.lastVoteTime, 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() newEventMap.forEach((entry, threadId) => { 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, { ...entry, @@ -537,8 +593,21 @@ const DiscussionsPage = forwardRef((_, ref) => { passesTimeFilter = mostRecentActivity > timeSpanAgo } - // Filter by topic - const passesTopicFilter = selectedTopic === 'all' || entry.categorizedTopic === selectedTopic + // Filter by topic (including group filtering) + 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) { filteredMap.set(entry.event.id, entry) @@ -746,27 +815,35 @@ const DiscussionsPage = forwardRef((_, ref) => { const threadId = publishedEvent.id 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 allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])] + const allTopicsRaw = [...new Set([...tTagsRaw, ...hashtagsRaw])] const predefinedTopicIds = DISCUSSION_TOPICS.map((t: any) => t.id) - const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds) const tTags = tTagsRaw.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 relaySources = eventHints.length > 0 ? eventHints : ['unknown'] + // Extract group information + const groupInfo = extractGroupInfo(publishedEvent, relaySources) + const categorizedTopic = getTopicFromTags(allTopicsRaw, predefinedTopicIds, groupInfo.isGroupDiscussion) + const newEntry: EventMapEntry = { - event: publishedEvent, - relaySources, - tTags, - hashtags, - allTopics, + event: publishedEvent, + relaySources, + tTags, + hashtags, + allTopics, categorizedTopic, commentCount: 0, lastCommentTime: 0, lastVoteTime: 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)) diff --git a/src/providers/GroupListProvider.tsx b/src/providers/GroupListProvider.tsx new file mode 100644 index 0000000..3517985 --- /dev/null +++ b/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 + isLoading: boolean +} + +const GroupListContext = createContext(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([]) + 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 ( + + {children} + + ) +}