Added ability for task authors to delete their own tasks from the expanded view in the task feed. **Features:** - Delete button visible only to task author in expanded task view - Confirmation dialog with destructive styling - Publishes NIP-09 deletion event (kind 5) with 'a' tag referencing the task's event address (kind:pubkey:d-tag format) - Real-time deletion handling via FeedService routing - Optimistic local state update for immediate UI feedback **Implementation:** - Added deleteTask() method to ScheduledEventService - Added handleTaskDeletion() for processing incoming deletion events - Updated FeedService to route kind 31922 deletions to ScheduledEventService - Added delete button and dialog flow to ScheduledEventCard component - Integrated with existing confirmation dialog pattern **Permissions:** - Only task authors can delete tasks (enforced by isAuthor check) - NIP-09 validation: relays only accept deletion from event author - Pubkey verification in handleTaskDeletion() **Testing:** - Created tasks and verified delete button appears for author only - Confirmed deletion removes task from UI immediately - Verified deletion persists after refresh - Tested with multiple users - others cannot delete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
635 lines
20 KiB
Vue
635 lines
20 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,
|
|
getTaskStatus,
|
|
claimTask,
|
|
startTask,
|
|
completeEvent,
|
|
unclaimTask,
|
|
deleteTask,
|
|
allCompletions
|
|
} = useScheduledEvents()
|
|
|
|
// Selected date for viewing scheduled tasks (defaults to today)
|
|
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
|
|
|
// Get scheduled tasks 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 Tasks"
|
|
} else if (selectedDate.value === yesterdayStr) {
|
|
return "Yesterday's Tasks"
|
|
} else if (selectedDate.value === tomorrowStr) {
|
|
return "Tomorrow's Tasks"
|
|
} else {
|
|
// Format as "Tasks 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 `Tasks 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)
|
|
}
|
|
}
|
|
|
|
// Task action handlers
|
|
async function onClaimTask(event: ScheduledEvent, occurrence?: string) {
|
|
console.log('👋 NostrFeed: Claiming task:', event.title)
|
|
try {
|
|
await claimTask(event, '', occurrence)
|
|
} catch (error) {
|
|
console.error('❌ Failed to claim task:', error)
|
|
}
|
|
}
|
|
|
|
async function onStartTask(event: ScheduledEvent, occurrence?: string) {
|
|
console.log('▶️ NostrFeed: Starting task:', event.title)
|
|
try {
|
|
await startTask(event, '', occurrence)
|
|
} catch (error) {
|
|
console.error('❌ Failed to start task:', error)
|
|
}
|
|
}
|
|
|
|
async function onCompleteTask(event: ScheduledEvent, occurrence?: string) {
|
|
console.log('✅ NostrFeed: Completing task:', event.title)
|
|
try {
|
|
await completeEvent(event, occurrence, '')
|
|
} catch (error) {
|
|
console.error('❌ Failed to complete task:', error)
|
|
}
|
|
}
|
|
|
|
async function onUnclaimTask(event: ScheduledEvent, occurrence?: string) {
|
|
console.log('🔙 NostrFeed: Unclaiming task:', event.title)
|
|
try {
|
|
await unclaimTask(event, occurrence)
|
|
} catch (error) {
|
|
console.error('❌ Failed to unclaim task:', error)
|
|
}
|
|
}
|
|
|
|
async function onDeleteTask(event: ScheduledEvent) {
|
|
console.log('🗑️ NostrFeed: Deleting task:', event.title)
|
|
try {
|
|
await deleteTask(event)
|
|
} catch (error) {
|
|
console.error('❌ Failed to delete task:', 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 Tasks Section with Date Navigation -->
|
|
<div class="my-2 md:my-4">
|
|
<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>
|
|
|
|
<!-- Scheduled Tasks 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"
|
|
:get-task-status="getTaskStatus"
|
|
:admin-pubkeys="adminPubkeys"
|
|
@claim-task="onClaimTask"
|
|
@start-task="onStartTask"
|
|
@complete-task="onCompleteTask"
|
|
@unclaim-task="onUnclaimTask"
|
|
@delete-task="onDeleteTask"
|
|
/>
|
|
</div>
|
|
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4">
|
|
{{ isToday ? 'no tasks today' : 'no tasks 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>
|