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.
585 lines
19 KiB
Vue
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>
|