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 @@ + + + + + Quick Filters + + + {{ preset.label }} + + + + + + + + Content Types + + {{ showCustom ? 'Hide' : 'Customize' }} + + + + + + + + + + + + {{ filter.label }} + + + {{ filter.description }} + + + + Kind{{ filter.kinds.length > 1 ? 's' : '' }}: {{ filter.kinds.join(', ') }} + + + Auth Required + + + Admin Only + + + Community + + + + + + + + + + Apply Custom Filters ({{ selectedFilters.length }}) + + + + + + + + + Active Filters + + + {{ filter.label }} + + + + + + + + + + \ 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 })
+ {{ filter.description }} +