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.
This commit is contained in:
padreug 2025-09-16 21:58:24 +02:00
parent a5e6c301e1
commit e90c4992da
10 changed files with 574 additions and 24 deletions

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from "reka-ui"
import { CollapsibleRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot
v-slot="{ open }"
data-slot="collapsible"
v-bind="forwarded"
>
<slot :open="open" />
</CollapsibleRoot>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleContentProps } from "reka-ui"
import { CollapsibleContent } from "reka-ui"
const props = defineProps<CollapsibleContentProps>()
</script>
<template>
<CollapsibleContent
data-slot="collapsible-content"
v-bind="props"
>
<slot />
</CollapsibleContent>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleTriggerProps } from "reka-ui"
import { CollapsibleTrigger } from "reka-ui"
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger
data-slot="collapsible-trigger"
v-bind="props"
>
<slot />
</CollapsibleTrigger>
</template>

View file

@ -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"

View file

@ -0,0 +1,214 @@
<template>
<div class="space-y-4">
<!-- Quick Presets -->
<div class="space-y-2">
<h3 class="text-sm font-medium">Quick Filters</h3>
<div class="flex flex-wrap gap-2">
<Button
v-for="preset in presets"
:key="preset.id"
variant="outline"
size="sm"
:class="selectedPreset === preset.id ? 'bg-primary text-primary-foreground' : ''"
@click="selectPreset(preset.id)"
>
{{ preset.label }}
</Button>
</div>
</div>
<!-- Custom Filter Selection -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">Content Types</h3>
<Button
variant="ghost"
size="sm"
@click="showCustom = !showCustom"
>
{{ showCustom ? 'Hide' : 'Customize' }}
<ChevronDown :class="showCustom ? 'rotate-180' : ''" class="ml-1 h-3 w-3 transition-transform" />
</Button>
</div>
<Collapsible v-model:open="showCustom">
<CollapsibleContent class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
v-for="filter in availableFilters"
:key="filter.id"
class="flex items-start space-x-3 p-3 border rounded-lg hover:bg-accent/50 transition-colors"
>
<Checkbox
:id="filter.id"
:checked="isFilterSelected(filter.id)"
@update:checked="toggleFilter(filter)"
/>
<div class="flex-1 min-w-0">
<Label
:for="filter.id"
class="text-sm font-medium cursor-pointer"
>
{{ filter.label }}
</Label>
<p class="text-xs text-muted-foreground mt-1">
{{ filter.description }}
</p>
<div class="flex items-center gap-2 mt-1">
<Badge variant="secondary" class="text-xs">
Kind{{ filter.kinds.length > 1 ? 's' : '' }}: {{ filter.kinds.join(', ') }}
</Badge>
<Badge v-if="filter.requiresAuth" variant="outline" class="text-xs">
Auth Required
</Badge>
<Badge
v-if="filter.filterByAuthor === 'admin'"
variant="default"
class="text-xs"
>
Admin Only
</Badge>
<Badge
v-if="filter.filterByAuthor === 'exclude-admin'"
variant="outline"
class="text-xs"
>
Community
</Badge>
</div>
</div>
</div>
</div>
<!-- Apply Custom Filters Button -->
<div class="flex justify-end pt-2">
<Button @click="applyCustomFilters" :disabled="selectedFilters.length === 0">
Apply Custom Filters ({{ selectedFilters.length }})
</Button>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<!-- Active Filters Display -->
<div v-if="activeFilters.length > 0" class="space-y-2">
<h3 class="text-sm font-medium">Active Filters</h3>
<div class="flex flex-wrap gap-2">
<Badge
v-for="filter in activeFilters"
:key="filter.id"
variant="default"
class="text-xs"
>
{{ filter.label }}
<Button
variant="ghost"
size="sm"
class="ml-1 h-3 w-3 p-0"
@click="removeFilter(filter)"
>
<X class="h-2 w-2" />
</Button>
</Badge>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'
import { ChevronDown, X } from 'lucide-vue-next'
import { FILTER_PRESETS, getAllContentFilters } from '../config/content-filters'
import type { ContentFilter } from '../services/FeedService'
interface Props {
modelValue: ContentFilter[]
adminPubkeys?: string[]
}
interface Emits {
(e: 'update:modelValue', value: ContentFilter[]): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// UI state
const showCustom = ref(false)
const selectedPreset = ref<string>('all')
const selectedFilters = ref<ContentFilter[]>([])
// Available filters (excluding auth-required ones if no auth)
const availableFilters = computed(() => {
return getAllContentFilters().filter(() => {
// For now, include all filters - auth check can be added later
return true
})
})
// Quick presets for common use cases
const presets = computed(() => [
{ id: 'all', label: 'All Content' },
{ id: 'announcements', label: 'Announcements' },
{ id: 'community', label: 'Community' },
{ id: 'marketplace', label: 'Marketplace' },
{ id: 'social', label: 'Social' },
{ id: 'events', label: 'Events' },
{ id: 'content', label: 'Articles' }
])
// Current active filters
const activeFilters = computed(() => props.modelValue)
// Check if a filter is selected
function isFilterSelected(filterId: string): boolean {
return selectedFilters.value.some(f => f.id === filterId)
}
// Toggle individual filter
function toggleFilter(filter: ContentFilter): void {
const index = selectedFilters.value.findIndex(f => f.id === filter.id)
if (index >= 0) {
selectedFilters.value.splice(index, 1)
} else {
selectedFilters.value.push(filter)
}
}
// Select a preset
function selectPreset(presetId: string): void {
selectedPreset.value = presetId
const presetFilters = FILTER_PRESETS[presetId] || []
emit('update:modelValue', presetFilters)
showCustom.value = false
}
// Apply custom filters
function applyCustomFilters(): void {
selectedPreset.value = 'custom'
emit('update:modelValue', [...selectedFilters.value])
showCustom.value = false
}
// Remove a filter
function removeFilter(filter: ContentFilter): void {
const newFilters = activeFilters.value.filter(f => f.id !== filter.id)
emit('update:modelValue', newFilters)
// Update selectedFilters for custom mode
const index = selectedFilters.value.findIndex(f => f.id === filter.id)
if (index >= 0) {
selectedFilters.value.splice(index, 1)
}
}
// Initialize selected filters when component mounts
watch(() => props.modelValue, (newFilters) => {
selectedFilters.value = [...newFilters]
}, { immediate: true })
</script>

View file

@ -8,20 +8,24 @@ import { formatDistanceToNow } from 'date-fns'
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next' import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
import { useFeed } from '../composables/useFeed' import { useFeed } from '../composables/useFeed'
import appConfig from '@/app.config' import appConfig from '@/app.config'
import type { ContentFilter } from '../services/FeedService'
const props = defineProps<{ const props = defineProps<{
relays?: string[] relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general' feedType?: 'all' | 'announcements' | 'events' | 'general' | 'custom'
contentFilters?: ContentFilter[]
adminPubkeys?: string[]
}>() }>()
// Get admin/moderator pubkeys from app config // Get admin/moderator pubkeys from props or app config
const adminPubkeys = appConfig.modules['nostr-feed'].config.adminPubkeys const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
// Use centralized feed service - this handles all subscription management and deduplication // Use centralized feed service - this handles all subscription management and deduplication
const { posts: notes, isLoading, error, refreshFeed } = useFeed({ const { posts: notes, isLoading, error, refreshFeed } = useFeed({
feedType: props.feedType || 'all', feedType: props.feedType || 'all',
maxPosts: 100, maxPosts: 100,
adminPubkeys adminPubkeys,
contentFilters: props.contentFilters
}) })
// Check if we have admin pubkeys configured // Check if we have admin pubkeys configured

View file

@ -1,12 +1,13 @@
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' 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 { export interface UseFeedConfig {
feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' | 'custom'
maxPosts?: number maxPosts?: number
refreshInterval?: number refreshInterval?: number
adminPubkeys?: string[] adminPubkeys?: string[]
contentFilters?: ContentFilter[]
} }
export function useFeed(config: UseFeedConfig) { export function useFeed(config: UseFeedConfig) {
@ -23,7 +24,8 @@ export function useFeed(config: UseFeedConfig) {
const feedConfig: FeedConfig = { const feedConfig: FeedConfig = {
feedType: config.feedType, feedType: config.feedType,
maxPosts: config.maxPosts, maxPosts: config.maxPosts,
adminPubkeys: config.adminPubkeys adminPubkeys: config.adminPubkeys,
contentFilters: config.contentFilters
} }
const filteredPosts = computed(() => { const filteredPosts = computed(() => {

View file

@ -0,0 +1,165 @@
import type { ContentFilter } from '../services/FeedService'
/**
* Predefined content filters for different types of Nostr content
*/
export const CONTENT_FILTERS: Record<string, ContentFilter> = {
// 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<string, ContentFilter[]> = {
// 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)
}

View file

@ -15,10 +15,20 @@ export interface FeedPost {
replyTo?: string replyTo?: string
} }
export interface ContentFilter {
id: string
label: string
kinds: number[]
description: string
requiresAuth?: boolean
filterByAuthor?: 'admin' | 'exclude-admin' | 'none'
}
export interface FeedConfig { export interface FeedConfig {
feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' feedType: 'announcements' | 'general' | 'mentions' | 'events' | 'all' | 'custom'
maxPosts?: number maxPosts?: number
adminPubkeys?: string[] adminPubkeys?: string[]
contentFilters?: ContentFilter[]
} }
export class FeedService extends BaseService { export class FeedService extends BaseService {
@ -109,29 +119,55 @@ export class FeedService extends BaseService {
// Create subscription ID // Create subscription ID
const subscriptionId = `feed-service-${config.feedType}-${Date.now()}` const subscriptionId = `feed-service-${config.feedType}-${Date.now()}`
// Create filter // Create filters based on feed type and content filters
const filter: Filter = { const filters: Filter[] = []
kinds: [1], // Text notes
limit: config.maxPosts || 50
}
if (config.feedType === 'announcements') { if (config.feedType === 'custom' && config.contentFilters) {
if (config.adminPubkeys && config.adminPubkeys.length > 0) { // Use custom content filters
filter.authors = config.adminPubkeys for (const contentFilter of config.contentFilters) {
} else { const filter: Filter = {
// No admin pubkeys configured for announcements - don't subscribe kinds: contentFilter.kinds,
console.log('No admin pubkeys configured for announcements feed') limit: Math.floor((config.maxPosts || 50) / config.contentFilters.length)
this._isLoading.value = false }
return
// 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 // Subscribe to events with deduplication
const unsubscribe = this.relayHub.subscribe({ const unsubscribe = this.relayHub.subscribe({
id: subscriptionId, id: subscriptionId,
filters: [filter], filters: filters,
onEvent: (event: NostrEvent) => { onEvent: (event: NostrEvent) => {
this.handleNewEvent(event, config) this.handleNewEvent(event, config)
}, },
@ -223,6 +259,26 @@ export class FeedService extends BaseService {
private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean { private shouldIncludeEvent(event: NostrEvent, config: FeedConfig): boolean {
const isAdminPost = config.adminPubkeys?.includes(event.pubkey) || false 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) { switch (config.feedType) {
case 'announcements': case 'announcements':
return isAdminPost return isAdminPost

View file

@ -3,7 +3,30 @@
<PWAInstallPrompt auto-show /> <PWAInstallPrompt auto-show />
<!-- TODO: Implement push notifications properly - currently commenting out admin notifications dialog --> <!-- TODO: Implement push notifications properly - currently commenting out admin notifications dialog -->
<!-- <NotificationPermission auto-show /> --> <!-- <NotificationPermission auto-show /> -->
<NostrFeed feed-type="all" />
<!-- Feed Filter Controls -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Filter class="h-5 w-5" />
Content Filters
</CardTitle>
<CardDescription>
Choose what types of content you want to see in your feed
</CardDescription>
</CardHeader>
<CardContent>
<FeedFilters v-model="selectedFilters" :admin-pubkeys="adminPubkeys" />
</CardContent>
</Card>
<!-- Feed Content -->
<NostrFeed
:feed-type="feedType"
:content-filters="selectedFilters"
:admin-pubkeys="adminPubkeys"
:key="feedKey"
/>
</div> </div>
</template> </template>
@ -12,5 +35,39 @@
// No need to import it directly - use the modular version // No need to import it directly - use the modular version
// TODO: Re-enable when push notifications are properly implemented // TODO: Re-enable when push notifications are properly implemented
// import NotificationPermission from '@/components/notifications/NotificationPermission.vue' // 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 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<ContentFilter[]>(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 })
</script> </script>