web-app/src/modules/nostr-feed/components/NostrFeed.vue
padreug 3c20d1c584 Refactor NostrFeed module and remove marketplace components
- Deleted the MarketProduct component and associated market parsing logic, streamlining the NostrFeed module.
- Updated FeedService to exclude marketplace events from the main feed, ensuring clearer event management.
- Adjusted content filters to remove marketplace-related entries, enhancing the organization of content filtering.

These changes improve the clarity and efficiency of the NostrFeed module by separating marketplace functionality.
2025-09-23 23:59:37 +02:00

320 lines
11 KiB
Vue

<script setup lang="ts">
import { computed, watch } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatDistanceToNow } from 'date-fns'
import { Megaphone, RefreshCw, AlertCircle, Reply, Heart, Share } from 'lucide-vue-next'
import { useFeed } from '../composables/useFeed'
import { useProfiles } from '../composables/useProfiles'
import { useReactions } from '../composables/useReactions'
import appConfig from '@/app.config'
import type { ContentFilter } from '../services/FeedService'
interface Emits {
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
}
const props = defineProps<{
relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general' | 'custom'
contentFilters?: ContentFilter[]
adminPubkeys?: string[]
compactMode?: boolean
}>()
const emit = defineEmits<Emits>()
// 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,
contentFilters: props.contentFilters
})
// Use profiles service for display names
const { getDisplayName, fetchProfiles } = useProfiles()
// Use reactions service for likes/hearts
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
// Watch for new posts and fetch their profiles and reactions
watch(notes, async (newNotes) => {
if (newNotes.length > 0) {
const pubkeys = [...new Set(newNotes.map(note => note.pubkey))]
const eventIds = newNotes.map(note => note.id)
// Fetch profiles and subscribe to reactions in parallel
await Promise.all([
fetchProfiles(pubkeys),
subscribeToReactions(eventIds)
])
}
}, { immediate: true })
// Check if we have admin pubkeys configured
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
// Get feed title and description based on type
const feedTitle = computed(() => {
switch (props.feedType) {
case 'announcements':
return 'Community Announcements'
case 'events':
return 'Events & Calendar'
case 'general':
return 'General Discussion'
default:
return 'Community Feed'
}
})
const feedDescription = computed(() => {
switch (props.feedType) {
case 'announcements':
return 'Important announcements from community administrators'
case 'events':
return 'Upcoming events and calendar updates'
case 'general':
return 'Community discussions and general posts'
default:
return 'Latest posts from the community'
}
})
// Check if a post is from an admin
function isAdminPost(pubkey: string): boolean {
return adminPubkeys.includes(pubkey)
}
// Check if a post is a rideshare post
function isRidesharePost(note: any): boolean {
// Check for rideshare tags
const hasTags = note.tags?.some((tag: string[]) =>
tag[0] === 't' && ['rideshare', 'carpool'].includes(tag[1])
) || false
// Check for rideshare-specific custom tags
const hasRideshareTypeTags = note.tags?.some((tag: string[]) =>
tag[0] === 'rideshare_type' && ['offering', 'seeking'].includes(tag[1])
) || false
// Check content for rideshare keywords (fallback)
const hasRideshareContent = note.content && (
note.content.includes('🚗 OFFERING RIDE') ||
note.content.includes('🚶 SEEKING RIDE') ||
note.content.includes('#rideshare') ||
note.content.includes('#carpool')
)
return hasTags || hasRideshareTypeTags || hasRideshareContent
}
// Get rideshare type from post
function getRideshareType(note: any): string | null {
// Check custom tags first
const typeTag = note.tags?.find((tag: string[]) => tag[0] === 'rideshare_type')
if (typeTag) {
return typeTag[1] === 'offering' ? 'Offering Ride' : 'Seeking Ride'
}
// Fallback to content analysis
if (note.content?.includes('🚗 OFFERING RIDE')) return 'Offering Ride'
if (note.content?.includes('🚶 SEEKING RIDE')) return 'Seeking Ride'
return 'Rideshare'
}
// Handle reply to note
function onReplyToNote(note: any) {
emit('reply-to-note', {
id: note.id,
content: note.content,
pubkey: note.pubkey
})
}
// Handle like/heart reaction toggle
async function onToggleLike(note: any) {
try {
await toggleLike(note.id, note.pubkey, note.kind)
} catch (error) {
console.error('Failed to toggle like:', error)
}
}
</script>
<template>
<div class="flex flex-col h-full">
<!-- Compact Header (only in non-compact mode) -->
<div v-if="!compactMode" class="flex items-center justify-between p-4 border-b">
<div class="flex items-center gap-2">
<Megaphone class="h-5 w-5 text-primary" />
<div>
<h2 class="text-lg font-semibold">{{ feedTitle }}</h2>
<p class="text-sm text-muted-foreground">{{ feedDescription }}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
@click="refreshFeed"
:disabled="isLoading"
class="gap-2"
>
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
Refresh
</Button>
</div>
<!-- Feed Content Container -->
<div class="flex-1 overflow-hidden">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="flex items-center gap-2">
<RefreshCw class="h-4 w-4 animate-spin" />
<span class="text-muted-foreground">Loading feed...</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8 px-4">
<div class="flex items-center justify-center gap-2 text-destructive mb-4">
<AlertCircle class="h-5 w-5" />
<span>Failed to load feed</span>
</div>
<p class="text-sm text-muted-foreground mb-4">{{ error }}</p>
<Button @click="refreshFeed" variant="outline">Try Again</Button>
</div>
<!-- No Admin Pubkeys Warning -->
<div v-else-if="!hasAdminPubkeys && props.feedType === 'announcements'" class="text-center py-8 px-4">
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
<Megaphone class="h-5 w-5" />
<span>No admin pubkeys configured</span>
</div>
<p class="text-sm text-muted-foreground">
Community announcements will appear here once admin pubkeys are configured.
</p>
</div>
<!-- No Posts -->
<div v-else-if="notes.length === 0" class="text-center py-8 px-4">
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
<Megaphone class="h-5 w-5" />
<span>No posts yet</span>
</div>
<p class="text-sm text-muted-foreground">
Check back later for community updates.
</p>
</div>
<!-- Posts List - Full height scroll -->
<div v-else class="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div>
<div v-for="(note, index) in notes" :key="note.id" :class="{ 'bg-muted/20': index % 2 === 1 }">
<!-- Text Posts and Other Event Types -->
<div
class="p-3 hover:bg-accent/50 transition-colors border-b border-border/40"
>
<!-- Note Header -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<Badge
v-if="isAdminPost(note.pubkey)"
variant="default"
class="text-xs px-1.5 py-0.5"
>
Admin
</Badge>
<Badge
v-if="note.isReply"
variant="secondary"
class="text-xs px-1.5 py-0.5"
>
Reply
</Badge>
<Badge
v-if="isRidesharePost(note)"
variant="secondary"
class="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
🚗 {{ getRideshareType(note) }}
</Badge>
<span class="text-sm font-medium">{{ getDisplayName(note.pubkey) }}</span>
</div>
<span class="text-xs text-muted-foreground">
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
</span>
</div>
<!-- Note Content -->
<div class="text-sm leading-relaxed whitespace-pre-wrap">
{{ note.content }}
</div>
<!-- Note Actions -->
<div class="mt-2 pt-2 border-t">
<div class="flex items-center justify-between">
<!-- Mentions -->
<div v-if="note.mentions.length > 0" class="flex items-center gap-1 text-xs text-muted-foreground">
<span>Mentions:</span>
<span v-for="mention in note.mentions.slice(0, 2)" :key="mention" class="font-mono">
{{ mention.slice(0, 6) }}...
</span>
<span v-if="note.mentions.length > 2" class="text-muted-foreground">
+{{ note.mentions.length - 2 }} more
</span>
</div>
<!-- Action Buttons - Compact on mobile -->
<div class="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
@click="onReplyToNote(note)"
>
<Reply class="h-3.5 w-3.5 sm:mr-1" />
<span class="hidden sm:inline">Reply</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
:class="{ 'text-red-500 hover:text-red-600': getEventReactions(note.id).userHasLiked }"
@click="onToggleLike(note)"
>
<Heart
class="h-3.5 w-3.5 sm:mr-1"
:class="{ 'fill-current': getEventReactions(note.id).userHasLiked }"
/>
<span class="hidden sm:inline">
{{ getEventReactions(note.id).userHasLiked ? 'Liked' : 'Like' }}
</span>
<span v-if="getEventReactions(note.id).likes > 0" class="ml-1 text-xs">
{{ getEventReactions(note.id).likes }}
</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-1.5 text-muted-foreground hover:text-foreground"
>
<Share class="h-3.5 w-3.5 sm:mr-1" />
<span class="hidden sm:inline">Share</span>
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>