web-app/src/modules/nostr-feed/components/NostrFeed.vue
padreug 7e698d2113 FIX: Show events even if no posts
Moves the "no posts" message to only display when there are no posts and no scheduled events.

Also, ensures "end of feed" message is displayed only when there are posts to show.
2025-10-31 22:00:19 +01:00

585 lines
19 KiB
Vue

<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useFeed } from '../composables/useFeed'
import { useProfiles } from '../composables/useProfiles'
import { useReactions } from '../composables/useReactions'
import { useScheduledEvents } from '../composables/useScheduledEvents'
import ThreadedPost from './ThreadedPost.vue'
import ScheduledEventCard from './ScheduledEventCard.vue'
import appConfig from '@/app.config'
import type { ContentFilter, FeedPost } from '../services/FeedService'
import type { ScheduledEvent } from '../services/ScheduledEventService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
import { finalizeEvent } from 'nostr-tools'
import { useToast } from '@/core/composables/useToast'
interface Emits {
(e: 'reply-to-note', note: { id: string; content: string; pubkey: string }): void
}
const props = defineProps<{
relays?: string[]
feedType?: 'all' | 'announcements' | 'rideshare' | 'custom'
contentFilters?: ContentFilter[]
adminPubkeys?: string[]
compactMode?: boolean
}>()
const emit = defineEmits<Emits>()
// Inject services
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const relayHub = injectService<RelayHub>(SERVICE_TOKENS.RELAY_HUB)
const toast = useToast()
// Get admin/moderator pubkeys from props or app config
const adminPubkeys = props.adminPubkeys || appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
// Get current user's pubkey
const currentUserPubkey = computed(() => authService?.user.value?.pubkey || null)
// Use centralized feed service - this handles all subscription management and deduplication
const { posts: notes, threadedPosts, isLoading, error, refreshFeed } = useFeed({
feedType: props.feedType || 'all',
maxPosts: 100,
adminPubkeys,
contentFilters: props.contentFilters
})
// Centralized collapse state management
const collapsedPosts = ref(new Set<string>())
// Track which posts should show limited replies (not collapsed, just limited)
const limitedReplyPosts = ref(new Set<string>())
// Delete confirmation dialog state
const showDeleteDialog = ref(false)
const postToDelete = ref<FeedPost | null>(null)
// Initialize posts that should show limited replies (>2 children)
watch(threadedPosts, (newPosts) => {
if (newPosts.length > 0) {
const newLimited = new Set<string>()
// Find posts with more than 2 direct replies - these should show limited replies by default
const findLimitedPosts = (posts: any[]) => {
posts.forEach(post => {
if ((post.replies?.length || 0) > 2) {
// Mark this post as having limited replies shown
newLimited.add(post.id)
}
// Recursively check nested replies
if (post.replies?.length > 0) {
findLimitedPosts(post.replies)
}
})
}
findLimitedPosts(newPosts)
limitedReplyPosts.value = newLimited
}
}, { immediate: true })
// Use profiles service for display names
const { getDisplayName, fetchProfiles } = useProfiles()
// Use reactions service for likes/hearts
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
// Use scheduled events service
const { getEventsForSpecificDate, getCompletion, toggleComplete, allCompletions } = useScheduledEvents()
// Selected date for viewing events (defaults to today)
const selectedDate = ref(new Date().toISOString().split('T')[0])
// Get scheduled events for the selected date (reactive)
const scheduledEventsForDate = computed(() => getEventsForSpecificDate(selectedDate.value))
// Navigate to previous day
function goToPreviousDay() {
const date = new Date(selectedDate.value)
date.setDate(date.getDate() - 1)
selectedDate.value = date.toISOString().split('T')[0]
}
// Navigate to next day
function goToNextDay() {
const date = new Date(selectedDate.value)
date.setDate(date.getDate() + 1)
selectedDate.value = date.toISOString().split('T')[0]
}
// Go back to today
function goToToday() {
selectedDate.value = new Date().toISOString().split('T')[0]
}
// Check if selected date is today
const isToday = computed(() => {
const today = new Date().toISOString().split('T')[0]
return selectedDate.value === today
})
// Format date for display
const dateDisplayText = computed(() => {
const today = new Date().toISOString().split('T')[0]
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().split('T')[0]
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const tomorrowStr = tomorrow.toISOString().split('T')[0]
if (selectedDate.value === today) {
return "Today's Events"
} else if (selectedDate.value === yesterdayStr) {
return "Yesterday's Events"
} else if (selectedDate.value === tomorrowStr) {
return "Tomorrow's Events"
} else {
// Format as "Events for Mon, Jan 15"
const date = new Date(selectedDate.value + 'T00:00:00')
const formatted = date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})
return `Events for ${formatted}`
}
})
// 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 })
// Watch for scheduled events and fetch profiles for event authors and completers
watch(scheduledEventsForDate, async (events) => {
if (events.length > 0) {
const pubkeys = new Set<string>()
// Add event authors
events.forEach((event: ScheduledEvent) => {
pubkeys.add(event.pubkey)
// Add completer pubkey if event is completed
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const completion = getCompletion(eventAddress)
if (completion) {
pubkeys.add(completion.pubkey)
}
})
// Fetch all profiles
if (pubkeys.size > 0) {
await fetchProfiles([...pubkeys])
}
}
}, { immediate: true })
// Watch for new completions and fetch profiles for completers
watch(allCompletions, async (completions) => {
if (completions.length > 0) {
const pubkeys = completions.map(c => c.pubkey)
await fetchProfiles(pubkeys)
}
}, { 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 'Announcements'
case 'rideshare':
return 'Rideshare'
case 'all':
default:
return 'All Content'
}
})
const feedDescription = computed(() => {
switch (props.feedType) {
case 'announcements':
return 'Important announcements from community administrators'
case 'rideshare':
return 'Rideshare requests and offers'
case 'all':
default:
return 'All community posts and updates'
}
})
// 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: FeedPost) {
try {
await toggleLike(note.id, note.pubkey, note.kind)
} catch (error) {
console.error('Failed to toggle like:', error)
}
}
// Handle scheduled event completion toggle
async function onToggleComplete(event: ScheduledEvent, occurrence?: string) {
console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title, 'occurrence:', occurrence)
try {
await toggleComplete(event, occurrence)
console.log('✅ NostrFeed: toggleComplete succeeded')
} catch (error) {
console.error('❌ NostrFeed: Failed to toggle event completion:', error)
}
}
// Handle collapse toggle with cascading behavior
function onToggleCollapse(postId: string) {
const newCollapsed = new Set(collapsedPosts.value)
if (newCollapsed.has(postId)) {
// Expand this post (remove from collapsed set)
newCollapsed.delete(postId)
} else {
// Collapse this post (add to collapsed set)
newCollapsed.add(postId)
// Find all descendant posts and collapse them too (cascading collapse)
const collapseDescendants = (posts: any[], targetId: string) => {
posts.forEach(post => {
if (post.id === targetId && post.replies) {
// Found the target post, collapse all its descendants
const addAllDescendants = (replies: any[]) => {
replies.forEach(reply => {
newCollapsed.add(reply.id)
if (reply.replies?.length > 0) {
addAllDescendants(reply.replies)
}
})
}
addAllDescendants(post.replies)
} else if (post.replies?.length > 0) {
// Keep searching in nested replies
collapseDescendants(post.replies, targetId)
}
})
}
collapseDescendants(threadedPosts.value, postId)
}
collapsedPosts.value = newCollapsed
}
// Handle toggle limited replies (show/hide extra replies beyond first 2)
function onToggleLimited(postId: string) {
const newLimited = new Set(limitedReplyPosts.value)
if (newLimited.has(postId)) {
// Show all replies
newLimited.delete(postId)
} else {
// Limit to first 2 replies
newLimited.add(postId)
}
limitedReplyPosts.value = newLimited
}
// Helper function to convert hex string to Uint8Array
const hexToUint8Array = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
// Handle delete post button click - show confirmation dialog
function onDeletePost(note: FeedPost) {
if (!authService?.isAuthenticated.value || !authService?.user.value) {
toast.error("Please sign in to delete posts")
return
}
// Verify user owns the post
if (note.pubkey !== currentUserPubkey.value) {
toast.error("You can only delete your own posts")
return
}
// Show confirmation dialog
postToDelete.value = note
showDeleteDialog.value = true
}
// Confirm and execute post deletion (NIP-09: Event Deletion Request)
async function confirmDeletePost() {
const note = postToDelete.value
if (!note) return
if (!relayHub?.isConnected.value) {
toast.error("Not connected to Nostr relays")
showDeleteDialog.value = false
postToDelete.value = null
return
}
const userPrivkey = authService?.user.value?.prvkey
if (!userPrivkey) {
toast.error("User private key not available")
showDeleteDialog.value = false
postToDelete.value = null
return
}
try {
// Create deletion event (NIP-09)
const deletionEvent = {
kind: 5, // Event Deletion Request
content: 'Deleted by author',
tags: [
['e', note.id], // Reference to event being deleted
['k', String(note.kind)] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the deletion event
const privkeyBytes = hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) {
toast.success(`Deletion request sent to ${result.success}/${result.total} relays`)
// The post will be removed automatically via websocket when relays broadcast the deletion event
} else {
toast.error("Failed to send deletion request to any relay")
}
} catch (error) {
console.error('Failed to delete post:', error)
toast.error(error instanceof Error ? error.message : "Failed to delete post")
} finally {
// Close dialog and clear state
showDeleteDialog.value = false
postToDelete.value = null
}
}
// Cancel delete action
function cancelDelete() {
showDeleteDialog.value = false
postToDelete.value = null
}
</script>
<template>
<div class="flex flex-col">
<!-- Compact Header (only in non-compact mode) -->
<div v-if="!compactMode" class="flex items-center justify-between p-4 md:p-6 border-b md:bg-card/50 md:backdrop-blur-sm">
<div class="w-full max-w-3xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<Megaphone class="h-5 w-5 md:h-6 md:w-6 text-primary" />
<div>
<h2 class="text-lg md:text-xl font-bold">{{ feedTitle }}</h2>
<p class="text-xs md:text-sm text-muted-foreground">{{ feedDescription }}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
@click="refreshFeed"
:disabled="isLoading"
class="gap-2 md:h-10 md:px-4 hover:bg-accent transition-colors"
>
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
<span class="hidden md:inline">Refresh</span>
</Button>
</div>
</div>
<!-- Feed Content Container -->
<div class="w-full max-w-3xl mx-auto px-0 md:px-4">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<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>
<!-- Posts List - Natural flow without internal scrolling -->
<div v-else>
<!-- Scheduled Events Section with Date Navigation -->
<div v-if="scheduledEventsForDate.length > 0 || !isToday" class="mb-6 md:mb-8">
<div class="flex items-center justify-between px-4 md:px-0 mb-3">
<!-- Left Arrow -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="goToPreviousDay"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<!-- Date Header with Today Button -->
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
📅 {{ dateDisplayText }}
</h3>
<Button
v-if="!isToday"
variant="outline"
size="sm"
class="h-6 text-xs"
@click="goToToday"
>
Today
</Button>
</div>
<!-- Right Arrow -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="goToNextDay"
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
<!-- Events List or Empty State -->
<div v-if="scheduledEventsForDate.length > 0" class="md:space-y-3">
<ScheduledEventCard
v-for="event in scheduledEventsForDate"
:key="`${event.pubkey}:${event.dTag}`"
:event="event"
:get-display-name="getDisplayName"
:get-completion="getCompletion"
:admin-pubkeys="adminPubkeys"
@toggle-complete="onToggleComplete"
/>
</div>
<div v-else class="text-center py-8 text-muted-foreground text-sm px-4">
No events scheduled for this day
</div>
</div>
<!-- Posts Section -->
<div v-if="threadedPosts.length > 0" class="md:space-y-4 md:py-4">
<h3 v-if="scheduledEventsForDate.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3 mt-6">
💬 Posts
</h3>
<ThreadedPost
v-for="post in threadedPosts"
:key="post.id"
:post="post"
:admin-pubkeys="adminPubkeys"
:current-user-pubkey="currentUserPubkey"
:get-display-name="getDisplayName"
:get-event-reactions="getEventReactions"
:depth="0"
:parent-collapsed="false"
:collapsed-posts="collapsedPosts"
:limited-reply-posts="limitedReplyPosts"
@reply-to-note="onReplyToNote"
@toggle-like="onToggleLike"
@toggle-collapse="onToggleCollapse"
@toggle-limited="onToggleLimited"
@delete-post="onDeletePost"
/>
</div>
<!-- No Posts Message (show whenever there are no posts, regardless of events) -->
<div v-else 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>
<!-- End of feed message -->
<div v-if="threadedPosts.length > 0" class="text-center py-6 text-md text-muted-foreground">
<p>🐢</p>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<Dialog :open="showDeleteDialog" @update:open="(val: boolean) => showDeleteDialog = val">
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Post?</DialogTitle>
<DialogDescription>
Are you sure you want to delete this post? This action cannot be undone. The deletion request will be sent to all relays.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="cancelDelete">Cancel</Button>
<Button variant="destructive" @click="confirmDeletePost">Delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>