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:
parent
a5e6c301e1
commit
e90c4992da
10 changed files with 574 additions and 24 deletions
19
src/components/ui/collapsible/Collapsible.vue
Normal file
19
src/components/ui/collapsible/Collapsible.vue
Normal 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>
|
||||||
15
src/components/ui/collapsible/CollapsibleContent.vue
Normal file
15
src/components/ui/collapsible/CollapsibleContent.vue
Normal 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>
|
||||||
15
src/components/ui/collapsible/CollapsibleTrigger.vue
Normal file
15
src/components/ui/collapsible/CollapsibleTrigger.vue
Normal 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>
|
||||||
3
src/components/ui/collapsible/index.ts
Normal file
3
src/components/ui/collapsible/index.ts
Normal 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"
|
||||||
214
src/modules/nostr-feed/components/FeedFilters.vue
Normal file
214
src/modules/nostr-feed/components/FeedFilters.vue
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
165
src/modules/nostr-feed/config/content-filters.ts
Normal file
165
src/modules/nostr-feed/config/content-filters.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue