- Introduced a new RideshareComposer component for creating rideshare posts, allowing users to specify ride details such as type, locations, date, time, and contact methods. - Enhanced NostrFeed.vue to display rideshare badges for relevant posts, improving visibility and categorization of rideshare content. - Updated Home.vue to integrate the RideshareComposer, providing users with an option to compose rideshare requests or offers alongside regular notes. These changes enhance the user experience by facilitating rideshare interactions within the NostrFeed module, promoting community engagement.
401 lines
14 KiB
Vue
401 lines
14 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'
|
|
import MarketProduct from './MarketProduct.vue'
|
|
import { parseMarketProduct, isMarketEvent, getMarketEventType } from '../utils/marketParser'
|
|
|
|
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'
|
|
}
|
|
|
|
// Get market product data for market events
|
|
function getMarketProductData(note: any) {
|
|
if (note.kind === 30018) {
|
|
// Create a mock NostrEvent from our FeedPost
|
|
const mockEvent = {
|
|
id: note.id,
|
|
pubkey: note.pubkey,
|
|
content: note.content,
|
|
created_at: note.created_at,
|
|
kind: note.kind,
|
|
tags: note.tags
|
|
}
|
|
return parseMarketProduct(mockEvent)
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Handle market product actions
|
|
function onViewProduct(productId: string) {
|
|
console.log('View product:', productId)
|
|
// TODO: Navigate to product detail page or open modal
|
|
}
|
|
|
|
function onShareProduct(productId: string) {
|
|
console.log('Share product:', productId)
|
|
// TODO: Implement sharing functionality
|
|
}
|
|
|
|
// 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 }">
|
|
<!-- Market Product Component (kind 30018) -->
|
|
<template v-if="note.kind === 30018">
|
|
<div class="p-3 border-b border-border/40">
|
|
<div class="mb-2 flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<Badge
|
|
v-if="isAdminPost(note.pubkey)"
|
|
variant="default"
|
|
class="text-xs"
|
|
>
|
|
Admin
|
|
</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>
|
|
<MarketProduct
|
|
v-if="getMarketProductData(note)"
|
|
:product="getMarketProductData(note)!"
|
|
@view-product="onViewProduct"
|
|
@share-product="onShareProduct"
|
|
/>
|
|
<!-- Fallback for invalid market data -->
|
|
<div
|
|
v-else
|
|
class="p-3 border rounded-lg border-destructive/20"
|
|
>
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<Badge variant="destructive" class="text-xs">
|
|
Invalid Market Product
|
|
</Badge>
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
|
|
</span>
|
|
</div>
|
|
<div class="text-sm text-muted-foreground">
|
|
Unable to parse market product data
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Regular Text Posts and Other Event Types -->
|
|
<div
|
|
v-else
|
|
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="isMarketEvent({ kind: note.kind })"
|
|
variant="outline"
|
|
class="text-xs px-1.5 py-0.5"
|
|
>
|
|
{{ getMarketEventType({ kind: note.kind }) }}
|
|
</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>
|