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 { 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

View file

@ -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(() => {

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
}
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,12 +119,35 @@ export class FeedService extends BaseService {
// Create subscription ID
const subscriptionId = `feed-service-${config.feedType}-${Date.now()}`
// Create filter
// Create filters based on feed type and content filters
const filters: Filter[] = []
if (config.feedType === 'custom' && config.contentFilters) {
// Use custom content filters
for (const contentFilter of config.contentFilters) {
const filter: Filter = {
kinds: [1], // Text notes
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
@ -126,12 +159,15 @@ export class FeedService extends BaseService {
}
}
console.log(`Creating feed subscription for ${config.feedType} with filter:`, filter)
filters.push(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

View file

@ -3,7 +3,30 @@
<PWAInstallPrompt auto-show />
<!-- TODO: Implement push notifications properly - currently commenting out admin notifications dialog -->
<!-- <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>
</template>
@ -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<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>