- 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.
320 lines
11 KiB
Vue
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>
|