From 584b801d8267ae476fa5ee2dabff10cd3893e900 Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Wed, 29 Oct 2025 15:36:45 +0100
Subject: [PATCH] h-tagged discussions
---
src/App.tsx | 35 ++---
src/components/DiscussionNote/index.tsx | 12 +-
src/constants.ts | 1 +
src/lib/discussion-topics.ts | 95 ++++++++++++
.../DiscussionsPage/CreateThreadDialog.tsx | 88 +++++++++++-
.../primary/DiscussionsPage/ThreadCard.tsx | 19 ++-
.../primary/DiscussionsPage/TopicFilter.tsx | 23 ++-
src/pages/primary/DiscussionsPage/index.tsx | 109 +++++++++++---
src/providers/GroupListProvider.tsx | 135 ++++++++++++++++++
9 files changed, 476 insertions(+), 41 deletions(-)
create mode 100644 src/providers/GroupListProvider.tsx
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}
+
+ )
+}