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' @@ -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 { @@ -35,22 +36,24 @@ export default function App(): JSX.Element {
<FollowListProvider>
<MuteListProvider>
<InterestListProvider>
<UserTrustProvider>
<BookmarksProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<UserPreferencesProvider>
<PageManager />
<Toaster />
</UserPreferencesProvider>
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>
</BookmarksProvider>
</UserTrustProvider>
<GroupListProvider>
<UserTrustProvider>
<BookmarksProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<UserPreferencesProvider>
<PageManager />
<Toaster />
</UserPreferencesProvider>
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>
</BookmarksProvider>
</UserTrustProvider>
</GroupListProvider>
</InterestListProvider>
</MuteListProvider>
</FollowListProvider>

12
src/components/DiscussionNote/index.tsx

@ -1,10 +1,11 @@ @@ -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 @@ -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 @@ -44,6 +48,12 @@ export default function DiscussionNote({ event, className, size = 'normal' }: Di
<topicInfo.icon className="w-3 h-3 mr-1" />
{topicInfo.label}
</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')}>
{t('Discussion')}
</span>

1
src/constants.ts

@ -132,6 +132,7 @@ export const ExtendedKind = { @@ -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,

95
src/lib/discussion-topics.ts

@ -225,3 +225,98 @@ export function getCategorizedTopic( @@ -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
}
}

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

@ -15,6 +15,7 @@ import { useState, useEffect, useMemo } from 'react' @@ -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 = [ @@ -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({ @@ -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<string[]>([])
const [selectableRelays, setSelectableRelays] = useState<string[]>([])
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)
@ -127,6 +130,10 @@ export default function CreateThreadDialog({ @@ -127,6 +130,10 @@ export default function CreateThreadDialog({
const [subject, setSubject] = useState('')
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
const allAvailableTopics = useMemo(() => {
const combined = [...DISCUSSION_TOPICS]
@ -255,7 +262,7 @@ export default function CreateThreadDialog({ @@ -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({ @@ -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({ @@ -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({ @@ -550,6 +569,65 @@ export default function CreateThreadDialog({
</p>
</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 */}
<div className="space-y-2">
<Label htmlFor="title">{t('Thread Title')}</Label>

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

@ -1,6 +1,6 @@ @@ -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' @@ -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({ @@ -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({ @@ -95,6 +98,12 @@ export default function ThreadCard({
<topicInfo.icon className="w-4 h-4" />
<span className="text-xs">{topicInfo.id}</span>
</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 => (
<Badge key={topic} variant="outline" className="text-xs">
<Hash className="w-3 h-3 mr-1" />
@ -142,6 +151,12 @@ export default function ThreadCard({ @@ -142,6 +151,12 @@ export default function ThreadCard({
<topicInfo.icon className="w-3 h-3 mr-1" />
{topicInfo.label}
</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 => (
<Badge key={topic} variant="outline" className="text-xs">
<Hash className="w-3 h-3 mr-1" />

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

@ -1,6 +1,6 @@ @@ -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 @@ -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 @@ -80,6 +88,19 @@ export default function TopicFilter({ topics, selectedTopic, onTopicChange, thre
<span className="ml-auto text-primary"></span>
)}
</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 => (
<DropdownMenuItem
key={topic.id}

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

@ -15,6 +15,7 @@ import { DISCUSSION_TOPICS } from './CreateThreadDialog' @@ -15,6 +15,7 @@ import { DISCUSSION_TOPICS } from './CreateThreadDialog'
import ThreadCard from './ThreadCard'
import CreateThreadDialog from './CreateThreadDialog'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { extractGroupInfo } from '@/lib/discussion-topics'
// Simple event map type
type EventMapEntry = {
@ -29,6 +30,11 @@ type EventMapEntry = { @@ -29,6 +30,11 @@ type EventMapEntry = {
lastVoteTime: number
upVotes: number
downVotes: number
// Group-related fields
groupId: string | null
groupRelay: string | null
groupDisplayName: string | null
isGroupDiscussion: boolean
}
// Vote counting function - separate and clean
@ -117,7 +123,12 @@ function countCommentsForThread(threadId: string, comments: NostrEvent[], thread @@ -117,7 +123,12 @@ function countCommentsForThread(threadId: string, comments: NostrEvent[], thread
}
// 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) {
if (predefinedTopicIds.includes(topic)) {
return topic
@ -201,6 +212,7 @@ function analyzeDynamicTopics(entries: EventMapEntry[]): { @@ -201,6 +212,7 @@ function analyzeDynamicTopics(entries: EventMapEntry[]): {
allTopics: DynamicTopic[]
} {
const hashtagCounts = new Map<string, number>()
const groupCounts = new Map<string, number>()
const predefinedTopicIds = DISCUSSION_TOPICS.map(t => t.id)
// Count hashtag frequency
@ -211,6 +223,11 @@ function analyzeDynamicTopics(entries: EventMapEntry[]): { @@ -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[]): { @@ -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[]): { @@ -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) => { @@ -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) => { @@ -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) => { @@ -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) => { @@ -462,7 +518,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
const updatedEventMap = new Map<string, EventMapEntry>()
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) => { @@ -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) => { @@ -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))

135
src/providers/GroupListProvider.tsx

@ -0,0 +1,135 @@ @@ -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