From e90c4992dad94530818094c45c0e82a793807d1b Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 16 Sep 2025 21:58:24 +0200 Subject: [PATCH] Add collapsible components and feed filter functionality - Introduced Collapsible, CollapsibleContent, and CollapsibleTrigger components for improved UI interactions. - Added FeedFilters component to allow users to customize content visibility in the NostrFeed. - Updated NostrFeed and Home components to integrate new filtering capabilities, enhancing user experience with customizable content display. - Implemented content filter logic in FeedService to support dynamic filtering based on user selections. These changes enhance the modularity and interactivity of the feed system, providing users with greater control over the content they see. --- src/components/ui/collapsible/Collapsible.vue | 19 ++ .../ui/collapsible/CollapsibleContent.vue | 15 ++ .../ui/collapsible/CollapsibleTrigger.vue | 15 ++ src/components/ui/collapsible/index.ts | 3 + .../nostr-feed/components/FeedFilters.vue | 214 ++++++++++++++++++ .../nostr-feed/components/NostrFeed.vue | 12 +- src/modules/nostr-feed/composables/useFeed.ts | 8 +- .../nostr-feed/config/content-filters.ts | 165 ++++++++++++++ .../nostr-feed/services/FeedService.ts | 88 +++++-- src/pages/Home.vue | 59 ++++- 10 files changed, 574 insertions(+), 24 deletions(-) create mode 100644 src/components/ui/collapsible/Collapsible.vue create mode 100644 src/components/ui/collapsible/CollapsibleContent.vue create mode 100644 src/components/ui/collapsible/CollapsibleTrigger.vue create mode 100644 src/components/ui/collapsible/index.ts create mode 100644 src/modules/nostr-feed/components/FeedFilters.vue create mode 100644 src/modules/nostr-feed/config/content-filters.ts diff --git a/src/components/ui/collapsible/Collapsible.vue b/src/components/ui/collapsible/Collapsible.vue new file mode 100644 index 0000000..b2efa1a --- /dev/null +++ b/src/components/ui/collapsible/Collapsible.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/collapsible/CollapsibleContent.vue b/src/components/ui/collapsible/CollapsibleContent.vue new file mode 100644 index 0000000..4b52fab --- /dev/null +++ b/src/components/ui/collapsible/CollapsibleContent.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/collapsible/CollapsibleTrigger.vue b/src/components/ui/collapsible/CollapsibleTrigger.vue new file mode 100644 index 0000000..52209f6 --- /dev/null +++ b/src/components/ui/collapsible/CollapsibleTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/collapsible/index.ts b/src/components/ui/collapsible/index.ts new file mode 100644 index 0000000..86a031d --- /dev/null +++ b/src/components/ui/collapsible/index.ts @@ -0,0 +1,3 @@ +export { default as Collapsible } from "./Collapsible.vue" +export { default as CollapsibleContent } from "./CollapsibleContent.vue" +export { default as CollapsibleTrigger } from "./CollapsibleTrigger.vue" diff --git a/src/modules/nostr-feed/components/FeedFilters.vue b/src/modules/nostr-feed/components/FeedFilters.vue new file mode 100644 index 0000000..2b7491e --- /dev/null +++ b/src/modules/nostr-feed/components/FeedFilters.vue @@ -0,0 +1,214 @@ + + + \ No newline at end of file diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue index fd77b26..a4a04f9 100644 --- a/src/modules/nostr-feed/components/NostrFeed.vue +++ b/src/modules/nostr-feed/components/NostrFeed.vue @@ -8,20 +8,24 @@ import { formatDistanceToNow } from 'date-fns' import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next' import { useFeed } from '../composables/useFeed' import appConfig from '@/app.config' +import type { ContentFilter } from '../services/FeedService' const props = defineProps<{ relays?: string[] - feedType?: 'all' | 'announcements' | 'events' | 'general' + feedType?: 'all' | 'announcements' | 'events' | 'general' | 'custom' + contentFilters?: ContentFilter[] + adminPubkeys?: string[] }>() -// Get admin/moderator pubkeys from app config -const adminPubkeys = appConfig.modules['nostr-feed'].config.adminPubkeys +// Get admin/moderator pubkeys from props or app config +const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || [] // Use centralized feed service - this handles all subscription management and deduplication const { posts: notes, isLoading, error, refreshFeed } = useFeed({ feedType: props.feedType || 'all', maxPosts: 100, - adminPubkeys + adminPubkeys, + contentFilters: props.contentFilters }) // Check if we have admin pubkeys configured diff --git a/src/modules/nostr-feed/composables/useFeed.ts b/src/modules/nostr-feed/composables/useFeed.ts index 111ff62..5456e92 100644 --- a/src/modules/nostr-feed/composables/useFeed.ts +++ b/src/modules/nostr-feed/composables/useFeed.ts @@ -1,12 +1,13 @@ import { computed, ref, onMounted, onUnmounted } from 'vue' import { injectService, SERVICE_TOKENS } from '@/core/di-container' -import type { FeedService, FeedConfig } from '../services/FeedService' +import type { FeedService, FeedConfig, ContentFilter } from '../services/FeedService' export interface UseFeedConfig { - feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' + feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' | 'custom' maxPosts?: number refreshInterval?: number adminPubkeys?: string[] + contentFilters?: ContentFilter[] } export function useFeed(config: UseFeedConfig) { @@ -23,7 +24,8 @@ export function useFeed(config: UseFeedConfig) { const feedConfig: FeedConfig = { feedType: config.feedType, maxPosts: config.maxPosts, - adminPubkeys: config.adminPubkeys + adminPubkeys: config.adminPubkeys, + contentFilters: config.contentFilters } const filteredPosts = computed(() => { diff --git a/src/modules/nostr-feed/config/content-filters.ts b/src/modules/nostr-feed/config/content-filters.ts new file mode 100644 index 0000000..f5455ea --- /dev/null +++ b/src/modules/nostr-feed/config/content-filters.ts @@ -0,0 +1,165 @@ +import type { ContentFilter } from '../services/FeedService' + +/** + * Predefined content filters for different types of Nostr content + */ +export const CONTENT_FILTERS: Record = { + // Text content + textNotes: { + id: 'text-notes', + label: 'Text Posts', + kinds: [1], // NIP-01: Short Text Note + description: 'Regular text posts and announcements' + }, + + // Admin/moderator announcements + adminAnnouncements: { + id: 'admin-announcements', + label: 'Admin Announcements', + kinds: [1], + description: 'Official announcements from administrators', + filterByAuthor: 'admin' + }, + + // General community posts (excluding admin) + communityPosts: { + id: 'community-posts', + label: 'Community Posts', + kinds: [1], + description: 'Posts from community members', + filterByAuthor: 'exclude-admin' + }, + + // Market content + marketStalls: { + id: 'market-stalls', + label: 'Market Stalls', + kinds: [30017], // NIP-15: Nostr Marketplace + description: 'Marketplace stall listings' + }, + + marketProducts: { + id: 'market-products', + label: 'Market Products', + kinds: [30018], // NIP-15: Nostr Marketplace + description: 'Product listings and updates' + }, + + marketGeneral: { + id: 'market-general', + label: 'Market Activity', + kinds: [30019], // NIP-15: Nostr Marketplace + description: 'General marketplace activity' + }, + + // Chat messages (if user wants to see them in feed) + chatMessages: { + id: 'chat-messages', + label: 'Chat Messages', + kinds: [4], // NIP-04: Encrypted Direct Messages + description: 'Private messages (requires authentication)', + requiresAuth: true + }, + + // Events and calendar + calendarEvents: { + id: 'calendar-events', + label: 'Calendar Events', + kinds: [31922, 31923], // NIP-52: Calendar Events + description: 'Calendar events and time-based activities' + }, + + // Long-form content + longFormContent: { + id: 'long-form', + label: 'Articles', + kinds: [30023], // NIP-23: Long-form Content + description: 'Long-form articles and blog posts' + }, + + // Reactions and social + reactions: { + id: 'reactions', + label: 'Reactions', + kinds: [7], // NIP-25: Reactions + description: 'Likes, reactions, and responses' + }, + + // Reposts/shares + reposts: { + id: 'reposts', + label: 'Reposts', + kinds: [6, 16], // NIP-18: Reposts, NIP-18: Generic Reposts + description: 'Shared and reposted content' + }, + + // Live events + liveEvents: { + id: 'live-events', + label: 'Live Events', + kinds: [30311], // NIP-53: Live Events + description: 'Live streaming and real-time events' + } +} + +/** + * Predefined filter combinations for common use cases + */ +export const FILTER_PRESETS: Record = { + // Basic presets + all: [ + CONTENT_FILTERS.textNotes, + CONTENT_FILTERS.marketStalls, + CONTENT_FILTERS.marketProducts, + CONTENT_FILTERS.marketGeneral, + CONTENT_FILTERS.calendarEvents, + CONTENT_FILTERS.longFormContent + ], + + announcements: [ + CONTENT_FILTERS.adminAnnouncements + ], + + community: [ + CONTENT_FILTERS.communityPosts, + CONTENT_FILTERS.reactions, + CONTENT_FILTERS.reposts + ], + + marketplace: [ + CONTENT_FILTERS.marketStalls, + CONTENT_FILTERS.marketProducts, + CONTENT_FILTERS.marketGeneral + ], + + social: [ + CONTENT_FILTERS.textNotes, + CONTENT_FILTERS.reactions, + CONTENT_FILTERS.reposts, + CONTENT_FILTERS.chatMessages + ], + + events: [ + CONTENT_FILTERS.calendarEvents, + CONTENT_FILTERS.liveEvents + ], + + content: [ + CONTENT_FILTERS.longFormContent, + CONTENT_FILTERS.textNotes + ] +} + +/** + * Get content filters by category + */ +export function getContentFiltersByCategory(category: keyof typeof FILTER_PRESETS): ContentFilter[] { + return FILTER_PRESETS[category] || [] +} + +/** + * Get all available content filters + */ +export function getAllContentFilters(): ContentFilter[] { + return Object.values(CONTENT_FILTERS) +} \ No newline at end of file diff --git a/src/modules/nostr-feed/services/FeedService.ts b/src/modules/nostr-feed/services/FeedService.ts index 42d653d..5a91fab 100644 --- a/src/modules/nostr-feed/services/FeedService.ts +++ b/src/modules/nostr-feed/services/FeedService.ts @@ -15,10 +15,20 @@ export interface FeedPost { replyTo?: string } +export interface ContentFilter { + id: string + label: string + kinds: number[] + description: string + requiresAuth?: boolean + filterByAuthor?: 'admin' | 'exclude-admin' | 'none' +} + export interface FeedConfig { - feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' + feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' | 'custom' maxPosts?: number adminPubkeys?: string[] + contentFilters?: ContentFilter[] } export class FeedService extends BaseService { @@ -109,29 +119,55 @@ export class FeedService extends BaseService { // Create subscription ID const subscriptionId = `feed-service-${config.feedType}-${Date.now()}` - // Create filter - const filter: Filter = { - kinds: [1], // Text notes - limit: config.maxPosts || 50 - } + // Create filters based on feed type and content filters + const filters: Filter[] = [] - if (config.feedType === 'announcements') { - if (config.adminPubkeys && config.adminPubkeys.length > 0) { - filter.authors = config.adminPubkeys - } else { - // No admin pubkeys configured for announcements - don't subscribe - console.log('No admin pubkeys configured for announcements feed') - this._isLoading.value = false - return + if (config.feedType === 'custom' && config.contentFilters) { + // Use custom content filters + for (const contentFilter of config.contentFilters) { + const filter: Filter = { + kinds: contentFilter.kinds, + limit: Math.floor((config.maxPosts || 50) / config.contentFilters.length) + } + + // Apply author filtering if specified + if (contentFilter.filterByAuthor === 'admin' && config.adminPubkeys?.length) { + filter.authors = config.adminPubkeys + } else if (contentFilter.filterByAuthor === 'exclude-admin' && config.adminPubkeys?.length) { + // Note: Nostr doesn't support negative filters natively, + // we'll filter these out in post-processing + } + + filters.push(filter) } + } else { + // Use legacy feed types + const filter: Filter = { + kinds: [1], // Text notes by default + limit: config.maxPosts || 50 + } + + // Handle legacy feed types + if (config.feedType === 'announcements') { + if (config.adminPubkeys && config.adminPubkeys.length > 0) { + filter.authors = config.adminPubkeys + } else { + // No admin pubkeys configured for announcements - don't subscribe + console.log('No admin pubkeys configured for announcements feed') + this._isLoading.value = false + return + } + } + + filters.push(filter) } - console.log(`Creating feed subscription for ${config.feedType} with filter:`, filter) + console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters) // Subscribe to events with deduplication const unsubscribe = this.relayHub.subscribe({ id: subscriptionId, - filters: [filter], + filters: filters, onEvent: (event: NostrEvent) => { this.handleNewEvent(event, config) }, @@ -223,6 +259,26 @@ export class FeedService extends BaseService { private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean { const isAdminPost = config.adminPubkeys?.includes(event.pubkey) || false + // For custom content filters, check if event matches any active filter + if (config.feedType === 'custom' && config.contentFilters) { + return config.contentFilters.some(filter => { + // Check if event kind matches + if (!filter.kinds.includes(event.kind)) { + return false + } + + // Apply author filtering + if (filter.filterByAuthor === 'admin') { + return isAdminPost + } else if (filter.filterByAuthor === 'exclude-admin') { + return !isAdminPost + } + + return true + }) + } + + // Legacy feed type handling switch (config.feedType) { case 'announcements': return isAdminPost diff --git a/src/pages/Home.vue b/src/pages/Home.vue index f880bf6..f7582f3 100644 --- a/src/pages/Home.vue +++ b/src/pages/Home.vue @@ -3,7 +3,30 @@ - + + + + + + + Content Filters + + + Choose what types of content you want to see in your feed + + + + + + + + + @@ -12,5 +35,39 @@ // No need to import it directly - use the modular version // TODO: Re-enable when push notifications are properly implemented // import NotificationPermission from '@/components/notifications/NotificationPermission.vue' +import { ref, computed, watch } from 'vue' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Filter } from 'lucide-vue-next' import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' +import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue' +import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters' +import appConfig from '@/app.config' +import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService' + +// Get admin pubkeys from app config +const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || [] + +// Feed configuration +const selectedFilters = ref(FILTER_PRESETS.all) +const feedKey = ref(0) // Force feed component to re-render when filters change + +// Determine feed type based on selected filters +const feedType = computed(() => { + if (selectedFilters.value.length === 0) return 'all' + + // Check if it matches a preset + for (const [presetName, presetFilters] of Object.entries(FILTER_PRESETS)) { + if (presetFilters.length === selectedFilters.value.length && + presetFilters.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) { + return presetName === 'all' ? 'all' : 'custom' + } + } + + return 'custom' +}) + +// Force feed to reload when filters change +watch(selectedFilters, () => { + feedKey.value++ +}, { deep: true })